13 Commits

Author SHA1 Message Date
Cowork Supervisor dffa0e152f Browse catalog dialog (issue #10 phase 2)
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 23s
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 11s
CI / Catalog signature (pull_request) Successful in 7s
Adds the user-facing half of the server catalog: a searchable, signed
catalog browser wired to the existing paste/import path.

bcc_core.py (pure, GUI-free, per the design comment on #10):
- CATALOG_CATEGORY_GROUPS / catalog_category_group(): collapses the
  raw taxonomy to the 7 UI chips (unknown categories fall back to
  Other, never drop an entry).
- catalog_entry_matches_query() / filter_catalog_entries() /
  catalog_entries_in_group(): search-as-you-type + category chip
  filtering over catalog entries.
- format_freshness_hint(): last_release ISO date -> "Last updated N
  months/years ago", '' when missing/unparseable/future.
- first_unfilled_focus_target(): finds the first <PLACEHOLDER> arg or
  blank env_required key so the GUI can focus it after Add.
- load_bundled_catalog_entries(): reads+verifies+validates the
  bundled catalog.json/.sig pair, returns servers or an EMPTY list on
  ANY failure -- the load-bearing guarantee behind the dialog's empty
  state.
- Bug fix: catalog_entry_to_paste_json() only copied config.env,
  ignoring env_required -- the field where 9 of the 19 seed entries
  (postgres, github, notion, obsidian, brave-search, tavily,
  home-assistant, n8n, grafana) actually declare their secret var
  names. Add would have silently added these servers with no env
  field for the user to fill in. Now env_required keys are seeded as
  empty-string placeholders unless config.env already sets them.

bcc.py:
- BrowseCatalogDialog: search box, category chips, list, detail pane
  (description/notes/homepage/freshness hint/verbatim command
  preview), per-tier action (Add for basic, Open setup docs for
  link-only). Every catalog-derived string goes through plain_label()
  (Qt.PlainText + html.escape, matching catalog_console.py's
  approach) or an inherently-plain QPlainTextEdit for the command
  preview -- catalog.json takes community PRs, so every field is
  attacker-influenceable.
- "Browse catalog…" button next to Paste JSON.
- ServerEditor.focus_target(): selects the first unfilled arg line or
  opens the env-table cell editor for the first blank env row.
- Save-time placeholder guard: warns (Yes/No, defaults No) when any
  server still has an unfilled <PLACEHOLDER>; never blocks a
  deliberate save, never saves one unnoticed.

bcc.spec: bundle data/catalog.json.sig alongside catalog.json -- the
signature file was missing from datas, so a frozen build would have
had a catalog.json with no matching .sig for resolve_catalog() to
verify against.

tests/test_core.py: +82 tests covering category-group mapping,
catalog search/filter, freshness-hint formatting (including the
month/year rounding boundary), first_unfilled_focus_target, the
catalog_entry_to_paste_json env_required fix (incl. a regression
against the real shipped postgres entry), and
load_bundled_catalog_entries under valid/tampered/wrong-key/missing-
file/invalid-but-signed conditions plus an end-to-end check against
the real data/catalog.json + .sig (19 entries).

GUI code (BrowseCatalogDialog, ServerEditor.focus_target,
MainWindow.browse_catalog) is unavoidably untested here -- PySide6
cannot import in this sandbox (missing libEGL/system GL libs) -- but
all the logic it depends on is pushed into bcc_core.py and covered
above.
2026-07-12 19:00:54 -04:00
the_og 6fce19cc67 ci: gate the catalog signature, smoke-test the release key (#61, #63)
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 10s
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.12 / windows-latest) (push) Successful in 23s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
CI / Catalog signature (push) Successful in 6s
Two gaps closed now that a real key exists.

1. CI 'Catalog signature' job (the #61 gate): every push/PR verifies
   data/catalog.json against data/catalog.json.sig using the public key in
   bcc_core, and runs validate_catalog. The threat model here is not an
   outsider pushing to the repo -- it is 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 now lands as
   a red build within a minute instead of quietly riding into the next
   release. Public-key only; no secret involved.

2. release.yml 'Signing key smoke test' (workflow_dispatch only): the
   Publish job is gated on a tag, so a manual run never exercised signing --
   a wrong or missing RELEASE_SIGNING_KEY would first surface during a real
   release. This signs a throwaway manifest with the secret and verifies it
   against the public key compiled into bcc_core, proving the two halves of
   the keypair actually match. Publishes nothing.
2026-07-12 18:30:30 -04:00
the_og 37b3c8f5d0 catalog: trust the real signing key
CI / Tests (py3.12 / windows-latest) (push) Successful in 23s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 9s
Adds the Ed25519 public key generated by the Catalog Console (#62),
replacing the b"\x00"*32 placeholder, and imports base64 (the key line
referenced it without the import, so bcc_core failed to load at all).

Verified end to end against the signature the Console pushed in b08cf21:
signature verifies, catalog validates clean, resolve_catalog accepts the
bundled copy (19 servers), and a single-byte tamper is rejected.
2026-07-12 18:25:49 -04:00
Cowork Supervisor b08cf2112b chore: sign data/catalog.json (Catalog Console, #62)
CI / Lint (ruff) (push) Successful in 8s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / windows-latest) (push) Successful in 22s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
Payload and detached Ed25519 signature land together so main is never red between a catalog merge and its signature.
2026-07-12 18:22:41 -04:00
the_og 80761a1f17 Merge PR #66: Catalog Console — maintainer review + signing tool (#62)
CI / Tests (py3.12 / windows-latest) (push) Successful in 22s
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 12s
Maintainer-only PySide6 tool: semantic per-entry diff of data/catalog.json,
blocking risk predicates (non-empty env values, command allowlist, non-ASCII
homoglyphs, unpinned packages, URL domain changes), automatic off-thread
registry lookups (publisher/age/downloads/near-neighbour), acknowledge-gated
signing with a pinned git blob SHA (refuses to sign bytes that changed since
review), and payload+signature emitted in a single commit.

Never shipped to users — excluded from bcc.spec, with a test asserting it.
2026-07-12 18:04:53 -04:00
BCC Agent d6fc6845c4 fix(catalog-console): run registry lookup automatically, not on click (#62)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 22s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
Review feedback: making the registry lookup an on-demand 'Check registry'
button was a deviation from the design intent, not a style choice. The
registry lookup is the one check a human reviewer genuinely cannot do by
eye -- it's what caught firecrawl-mcp's unrelated npm publisher in the
seed data. Gating it behind a button makes it optional, and an optional
check is the one a tired maintainer skips at 11pm -- exactly the failure
mode this tool exists to defend against. The friction belongs on
approval, never on information.

EntryCard now kicks off its registry lookup automatically at construction
time (i.e. as soon as _render_cards() builds the cards for a loaded
review), one RegistryLookupWorker (QThread) per changed entry, all
starting concurrently as the cards are built. Nothing about the lookup's
pure logic changed -- catalog_review.lookup_registry_info was already
fail-soft (RegistryInfo(available=False) on any fetch problem, never an
exception) and can_sign() never depended on registry state, so a dead
registry still cannot gate review or signing.

Renamed the button 'Check registry' -> 'Re-check' and kept it wired to
the same _run_registry_lookup(), for manually retrying a failed/unavailable
lookup. Label copy now reads loading... while a lookup is in flight (was
'not checked yet.' / 'checking...'), matching the loading -> result |
unavailable per-entry states.

No change to acknowledge-gating or the TOCTOU blob-SHA pin in
catalog_review.py. ruff check/format clean; full suite still 321 passed,
1 pre-existing unrelated skip -- catalog_review.py (the tested pure-logic
module) is untouched, only catalog_console.py's GUI wiring moved from
button-triggered to auto-triggered.
2026-07-12 18:04:28 -04:00
BCC Agent f0d0ab7a08 feat: Catalog Console -- maintainer-only review + signing tool (#62)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 23s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
Adds a separate PySide6 tool (catalog_console.py) that reviews proposed
changes to data/catalog.json and signs the approved result. Never shipped
to users, never in the release bundle -- maintainer runs it from source.

Pure, GUI-free logic lives in a new catalog_review.py (kept out of both
the GUI and bcc_core.py to avoid merge conflicts on the latter):

- diff_catalogs(old, new) -> list[EntryChange]: semantic (per-entry)
  diff, not a text diff, with per-field before/after values.
- Six independent risk predicates, each unit-tested: non-empty
  env_required value, command outside bcc_core.CATALOG_ALLOWED_COMMANDS
  (imported, not redefined), non-ASCII code points in id/command/args
  (rendered with escapes -- homoglyph/RTL-override defence), unpinned
  npm/docker package references, URL domain changes (lookalike-domain
  swap defence), brand-new entries flagged for extra scrutiny.
- ReviewSession + can_sign(): the Sign button stays disabled until every
  changed entry is individually acknowledged -- no "acknowledge all"
  shortcut exists, and a comment in the code says never to add one.
- TOCTOU fix (adversarial review on #62): the git blob SHA of
  data/catalog.json is pinned when review begins; can_sign() refuses to
  sign if the current blob differs, forcing a re-review. The Console
  re-fetches the blob SHA immediately before signing and enforces this.
- catalog_signing_message() imports bcc_core's domain-separation prefix
  (_CATALOG_SIG_DOMAIN) rather than retyping it, so the Console's
  signatures and bcc_core.verify_catalog_signature can't drift apart --
  proven by a round-trip test (sign here, verify via bcc_core).
- encrypt_private_key/decrypt_private_key: the signing key is never
  stored plaintext (scrypt + AES-256-GCM at rest, OS keychain via the
  optional keyring package if available, else an encrypted file under
  $HOME outside the repo).
- Registry lookup (lookup_registry_info + injected Fetcher): the network
  call is kept out of this module for offline testability;
  catalog_console.py supplies npm/PyPI HTTP fetchers. Fails soft --
  network down means "unavailable", never a block on review.
  near_neighbor_ids() flags edit-distance <=2 typosquat candidates
  against existing catalog ids.

catalog_console.py wires the above into a Qt GUI: Load (open PRs
touching data/catalog.json via the Gitea REST API, or main) -> Review
(one EntryCard per changed entry, command/args rendered visually
dominant, risk findings colour-coded, per-card registry-lookup button
running off the UI thread like bcc.py's ConnTester/SpawnTester) -> Sign
(re-checks the pinned blob SHA, prompts for the key passphrase, writes
data/catalog.json + data/catalog.json.sig and commits+pushes BOTH in a
single commit -- so main is never red between a catalog merge and its
signature). Every attacker-controlled string renders through a
plain_label() helper that both escapes HTML and forces Qt.PlainText, so
a script/image payload in a description/notes/URL can't render as
markup. Also provides keygen (generates + stores an encrypted keypair,
prints the base64 public key) and show-seed-b64 (prints the base64
private seed for the RELEASE_SIGNING_KEY CI secret) CLI subcommands.

Excluded from the release bundle: bcc.spec's Analysis() only ever starts
from bcc.py, and tests/test_catalog_console_packaging.py asserts neither
new file is named anywhere in bcc.spec and that bcc.py never imports
either module.

Tests: 67 new (62 in test_catalog_review.py, 5 in
test_catalog_console_packaging.py) covering diff_catalogs, every risk
predicate individually, the acknowledge-gating + TOCTOU can_sign()
logic, the sign/verify round-trip against bcc_core, key encryption
(including wrong-passphrase and corrupted-blob rejection),
edit-distance/near-neighbour matching, and registry-lookup fail-soft
behaviour. Full suite: 321 passed, 1 pre-existing unrelated skip. ruff
check and ruff format --check both clean. catalog_console.py (Qt/GUI)
could not be executed in the sandbox this was developed in (no system
EGL/GL libraries available for PySide6) -- it was syntax-checked
(py_compile) and lint/format-checked but not smoke-tested; see PR body
for what AJ should verify.

Closes #62
2026-07-12 17:57:00 -04:00
the_og 3841106630 Merge pull request 'feat: MCP server catalog core -- signed, validated, resolvable (#10, #61)' (#65) from feat/10-catalog-core into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.12 / windows-latest) (push) Successful in 22s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
2026-07-12 17:55:49 -04:00
the_og e3581b6e8b Merge branch 'main' into feat/10-catalog-core
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 43s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
2026-07-12 17:42:12 -04:00
the_og 672d78f903 Merge PR #64: signed SHA256SUMS for releases (#63)
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / windows-latest) (push) Successful in 41s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 11s
Publish SHA-256 checksums for every release artifact and sign them with a
domain-separated Ed25519 signature. Skips signing (loudly) if the key secret
is absent rather than failing the release. README documents verification and
states the limit plainly: this proves the file is the one we published; it
does not remove Gatekeeper/SmartScreen warnings.
2026-07-12 17:42:09 -04:00
the_og 88e93edc6c catalog: pin exact package versions, drop firecrawl, add last_release
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 16s
CI / Lint (ruff) (pull_request) Successful in 52s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 14s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 14s
- Pin every basic-tier entry to an exact published version (npm @x.y.z,
  uvx @x.y.z, docker :tag). Unpinned npx -y <pkg> means a package
  compromised AFTER we ship auto-upgrades into every user; a pin bounds
  supply-chain compromise to versions we actually reviewed.
- Drop firecrawl from the seed (19 entries). npm publish rights are held
  solely by hello_sideguide/sideguide.dev, which has no visible
  relationship to firecrawl.dev, while the package is presented as
  official. Publisher identity we cannot tie to the vendor is exactly
  what this catalog must not execute on a user's machine. Retained in the
  research pool pending confirmation.
- postgres: ship --access-mode=restricted, not unrestricted. A curated
  catalog must not default to handing an LLM write access to your DB.
- Add last_release (ISO date, from the live registry) so the UI can show
  freshness; postgres-mcp and mcp-obsidian are both ~14mo stale.
2026-07-12 17:35:48 -04:00
Cowork Agent 48904c7787 feat: MCP server catalog core -- signed, validated, resolvable (#10, #61)
CI / Lint (ruff) (pull_request) Successful in 12s
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 17s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 14s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 39s
Phase 1 of the MCP server catalog: pure, GUI-free core functions plus the
seed data/catalog.json (20 servers). No GUI wiring in this PR -- bcc.py is
untouched; a follow-up PR adds the picker dialog.

- load_catalog(): strict json.loads ONLY. The lenient repair pipeline
  (repair_json_text / parse_pasted_json*) is never used on catalog bytes,
  by design and by comment, so a signature always authenticates exactly
  what gets parsed.
- validate_catalog(): rejects the whole file (not per-entry) on: bad
  schema/version types, missing tier-appropriate fields (basic needs
  config.command+args, link-only needs docs_url and no config), a
  command allowlist (npx/uvx/docker/node/python/python3 only), -e/--eval/-c
  denial for node/python, --privileged and root/$HOME volume-mount denial
  for docker, non-empty env_required values (hard rejection -- secrets
  never ship in the catalog), secret-looking args (reuses
  _TOKEN_PREFIXES/_is_secret_value rather than reimplementing), non-https
  URL fields, and non-ASCII code points in id/command/args (homoglyph
  defence).
- verify_catalog_signature(): Ed25519 via the cryptography package,
  domain-separated message (the literal prefix "bcc-catalog-v1|" + raw
  bytes), accepts a match against any key in CATALOG_PUBKEYS
  (rotation-ready), never raises.
- resolve_catalog(): picks the highest version among bundled/cached/remote
  candidates that EACH independently pass verify + validate -- the bundled
  catalog gets no implicit trust, closing the hole where an unsigned
  payload merged to main would win on being local. Anti-rollback (never
  regress below the best verified candidate already in hand) and
  anti-freeze (reject a jump of more than 1000 versions) built in.
- catalog_entry_to_paste_json() / config_has_unfilled_placeholders(): small
  pure helpers the future GUI dialog will use to feed a catalog pick into
  the existing paste-import path and to gate Save on unfilled placeholder
  tokens.

data/catalog.json: the provided 20-server seed, with a signed_at field
added at the top level (lives inside the signed payload once real signing
lands in #62). Wired into bcc.spec's PyInstaller datas so it bundles into
the frozen app.

Security requirements from the issue, and where they landed:
- Catalog bytes never touch the lenient JSON repair path -- enforced by
  load_catalog()'s strict json.loads and a comment warning against wiring
  it in later.
- env_required values are a hard rejection when non-empty, not a warning.
- Secret-looking args are rejected at validation time, reusing the
  existing secret-detection helpers instead of duplicating them.
- Non-ASCII id/command/args rejected (typosquat/homoglyph defence).
- URL fields restricted to https://.
- Ed25519 signature verification is domain-separated and never raises.
- The bundled catalog is verified at runtime exactly like remote/cached --
  no implicit trust for being local.
- Anti-rollback and anti-freeze bounds on resolve_catalog's version
  comparison.

Tests: 42 new tests added to tests/test_core.py (full suite: 239 passed,
1 pre-existing unrelated skip). ruff check and ruff format --check both
clean.
2026-07-12 17:32:50 -04:00
BCC Agent cd38fd0c78 Sign release checksums with Ed25519 (#63)
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 23s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 13s
Publish SHA256SUMS for every release archive and sign it with a
detached Ed25519 signature (SHA256SUMS.sig), since paid code signing
(macOS Developer ID, Windows Authenticode) and Sigstore keyless (needs
a Fulcio-trusted OIDC issuer; self-hosted Gitea isn't one) are both
out of budget/scope.

- scripts/sign_checksums.py: dependency-light (cryptography only)
  helper to hash a directory of files into a sha256sum(1)-compatible
  SHA256SUMS manifest, sign it (domain-separated: b"bcc-release-v1|"
  + raw manifest bytes), and verify a signature. CLI has generate/
  sign/verify subcommands; verify doubles as the check path.
- tests/test_checksums.py: 15 unit + CLI-subprocess tests covering
  hashing, manifest formatting, sign/verify roundtrip, tamper
  detection, wrong-key rejection, domain-separation, and the
  no-key-provided failure path (must error, never write an empty/
  bogus .sig).
- .github/workflows/release.yml: Publish Release job now checks out
  the repo, flattens build artifacts, generates SHA256SUMS, and signs
  it from the RELEASE_SIGNING_KEY secret (base64 raw Ed25519 seed) if
  present. If the secret is absent, the release still publishes with
  a loud ::warning:: and no .sig — it never fails the release or
  publishes a bogus signature.
- README.md: new 'Verifying your download' section with the (still
  placeholder) public key, sha256sum -c / Get-FileHash commands, and
  an explicit statement that this does not remove Gatekeeper/
  SmartScreen warnings.
- requirements-dev.txt / ci.yml: add cryptography as a dev/test
  dependency for the new script and its tests.

Touches no files from bcc_core.py / tests/test_core.py /
pyproject.toml / bcc.spec to avoid colliding with concurrent work on
those files.
2026-07-12 17:30:09 -04:00
17 changed files with 5079 additions and 3 deletions
+60 -1
View File
@@ -62,8 +62,67 @@ jobs:
# 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
- name: Verify data/catalog.json.sig
run: |
python - <<'PY'
import pathlib, sys
import bcc_core as c
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.")
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 and the catalog validates clean.")
PY
+130 -1
View File
@@ -95,6 +95,69 @@ 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 the PUBLIC key already compiled into bcc_core.
#
# It proves the two halves of the 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 shipped 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 it with: python catalog_console.py show-seed-b64"
echo "then add it under Settings -> Actions -> Secrets."
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 base64, pathlib, sys
import bcc_core as c
from scripts.sign_checksums import verify_checksums
# The public half that ships inside the binary. If the secret is a
# DIFFERENT key than the one users' copies trust, this fails here --
# which is the entire point of the job.
pub_b64 = base64.b64encode(c.CATALOG_PUBKEYS[0]).decode()
sums = pathlib.Path("smoke/SHA256SUMS").read_text()
sig = pathlib.Path("smoke/SHA256SUMS.sig").read_bytes()
if not verify_checksums(pub_b64, sums, sig):
sys.exit(
"FAIL: the signature produced by RELEASE_SIGNING_KEY does NOT verify\n"
"against the public key in bcc_core.CATALOG_PUBKEYS.\n"
"\n"
"The secret and the shipped public key are different keypairs. Users\n"
"would reject every signature this CI produces. Re-copy the seed from\n"
"`catalog_console.py show-seed-b64`, or update CATALOG_PUBKEYS."
)
print("OK: RELEASE_SIGNING_KEY matches the public key shipped in bcc_core.")
PY
# ── Create GitHub Release with all three artifacts ────────────────────── # ── Create GitHub Release with all three artifacts ──────────────────────
release: release:
@@ -107,11 +170,72 @@ 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) generated via the Catalog Console (#62). 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. Add the secret (base64 raw Ed25519 seed, generated via the Catalog Console, #62) 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 +243,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 +265,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.
+60
View File
@@ -19,6 +19,65 @@ 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.
**Release signing public key** (Ed25519, base64, raw 32 bytes):
```
<PLACEHOLDER — AJ: paste the public key from the Catalog Console (#62) here>
```
### 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 public key above>"
```
### 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.
## Run from source ## Run from source
```bash ```bash
@@ -92,6 +151,7 @@ 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)).
## Building from source ## Building from source
+376
View File
@@ -10,6 +10,7 @@ Run: python mcp_manager.py
from __future__ import annotations from __future__ import annotations
import html
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
@@ -26,10 +27,12 @@ from PySide6.QtGui import (
QKeySequence, QKeySequence,
QPainter, QPainter,
QPixmap, QPixmap,
QTextCursor,
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QApplication, QApplication,
QButtonGroup,
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QDialog, QDialog,
@@ -65,6 +68,26 @@ import bcc_core as core
# thread during drag-and-drop import, so skip anything larger than this. # thread during drag-and-drop import, so skip anything larger than this.
MAX_DROP_IMPORT_BYTES = 5 * 1024 * 1024 # 5 MB MAX_DROP_IMPORT_BYTES = 5 * 1024 * 1024 # 5 MB
def plain_label(text: object) -> QLabel:
"""A QLabel guaranteed to render `text` as plain text, never HTML.
Qt's QLabel auto-interprets HTML by default (Qt.AutoText). Every catalog
entry field (description, notes, display name, urls -- and especially
args) is attacker-influenceable: catalog.json accepts community PRs, and
only a valid Ed25519 signature stands between a PR and what a user sees
here. A `<b>` or `<img onerror=...>` in a description must render as
visible text, not markup -- exactly the same reasoning catalog_console.py
documents for its own plain_label(). Every catalog-derived string shown
by the Browse dialog MUST go through this helper (or an inherently
plain-text widget like QPlainTextEdit) rather than a bare QLabel(...).
"""
label = QLabel(html.escape(str(text)))
label.setTextFormat(Qt.TextFormat.PlainText)
label.setWordWrap(True)
return label
# --- One-line rebrand: change this to recolor the whole app --------------- # # --- One-line rebrand: change this to recolor the whole app --------------- #
ACCENT = "#f97316" # warm orange ACCENT = "#f97316" # warm orange
ACCENT_DIM = "#c2570b" ACCENT_DIM = "#c2570b"
@@ -676,6 +699,39 @@ class ServerEditor(QFrame):
def current_name(self) -> str: def current_name(self) -> str:
return self.name.text().strip() return self.name.text().strip()
def focus_target(self, target: tuple[str, int | str] | None):
"""
Focus the field a catalog Add left unfilled -- `target` is whatever
core.first_unfilled_focus_target() returned: ("args", line_index),
("env", var_name), or None (nothing to fill, so do nothing).
Only meaningful on the stdio page, which is the only page a catalog
entry ever populates (link-only entries never reach dump_data()).
"""
if not target or self.type.currentIndex() != 0:
return
kind, value = target
if kind == "args":
self.args.setFocus()
cursor = self.args.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.Start)
cursor.movePosition(
QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.MoveAnchor, int(value)
)
cursor.movePosition(
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
)
self.args.setTextCursor(cursor)
elif kind == "env":
for r in range(self.env.table.rowCount()):
key_item = self.env.table.item(r, 0)
if key_item and key_item.text() == value:
self.env.table.setCurrentCell(r, 1)
val_item = self.env.table.item(r, 1)
if val_item:
self.env.table.editItem(val_item)
break
def _type_switched(self): def _type_switched(self):
self.stack.setCurrentIndex(self.type.currentIndex()) self.stack.setCurrentIndex(self.type.currentIndex())
self._emit() self._emit()
@@ -1204,6 +1260,262 @@ class PasteDialog(QDialog):
self.err.setText(str(e)) self.err.setText(str(e))
# --------------------------------------------------------------------------- #
# Browse catalog dialog (issue #10 phase 2): search/filter the signed,
# bundled server catalog and add a "basic" entry through the existing
# paste/import path, or send a "link-only" entry to its setup docs.
#
# Every widget here that shows catalog-derived text uses plain_label() or an
# inherently-plain widget (QPlainTextEdit) -- see plain_label()'s docstring.
# The dialog itself does no signature/schema work: MainWindow hands it an
# already-verified `entries` list (bcc_core.load_bundled_catalog_entries()),
# and an empty list here means "show the empty state", never "fall back to
# something less trusted".
# --------------------------------------------------------------------------- #
class BrowseCatalogDialog(QDialog):
def __init__(self, parent, entries: list[dict]):
super().__init__(parent)
self.setWindowTitle("Browse catalog")
self.resize(880, 560)
self.entries = entries or []
self.result_entry: dict | None = None
self._current_entry: dict | None = None
self._current_homepage: str | None = None
self._current_docs_url: str | None = None
self._current_group = "All"
outer = QVBoxLayout(self)
if not self.entries:
# Signature verification failed, or nothing was bundled -- never
# show a half-trusted list, and never explain WHY beyond this;
# a stale/tampered catalog isn't the user's problem to diagnose.
msg = plain_label("Catalog unavailable.")
msg.setObjectName("placeholder")
msg.setAlignment(Qt.AlignmentFlag.AlignCenter)
outer.addWidget(msg, 1)
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
btns.rejected.connect(self.reject)
outer.addWidget(btns)
return
search_row = QHBoxLayout()
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("Search by name, description, or category…")
self.search_box.setClearButtonEnabled(True)
self.search_box.textChanged.connect(self._refresh_list)
search_row.addWidget(self.search_box, 1)
outer.addLayout(search_row)
chip_row = QHBoxLayout()
self._chip_group = QButtonGroup(self)
self._chip_group.setExclusive(True)
for label in core.CATALOG_CATEGORY_CHIPS:
btn = QPushButton(label)
btn.setCheckable(True)
btn.setChecked(label == "All")
btn.clicked.connect(lambda _checked=False, g=label: self._set_group(g))
self._chip_group.addButton(btn)
chip_row.addWidget(btn)
chip_row.addStretch()
outer.addLayout(chip_row)
splitter = QSplitter(Qt.Orientation.Horizontal)
left = QWidget()
lv = QVBoxLayout(left)
lv.setContentsMargins(0, 0, 0, 0)
self.list = QListWidget()
self.list.currentItemChanged.connect(self._on_selected)
lv.addWidget(self.list, 1)
splitter.addWidget(left)
right = QFrame()
right.setObjectName("card")
rv = QVBoxLayout(right)
self.detail_title = plain_label("")
self.detail_title.setObjectName("h1")
rv.addWidget(self.detail_title)
self.detail_meta = plain_label("")
self.detail_meta.setObjectName("muted")
rv.addWidget(self.detail_meta)
self.detail_freshness = plain_label("")
self.detail_freshness.setObjectName("muted")
rv.addWidget(self.detail_freshness)
self.detail_desc = plain_label("")
rv.addWidget(self.detail_desc)
self.detail_notes = plain_label("")
self.detail_notes.setObjectName("muted")
rv.addWidget(self.detail_notes)
self.detail_homepage_btn = QPushButton("Open homepage")
self.detail_homepage_btn.clicked.connect(self._open_homepage)
rv.addWidget(self.detail_homepage_btn)
rv.addWidget(plain_label("Exact command this will add:"))
self.detail_command_preview = QPlainTextEdit()
self.detail_command_preview.setObjectName("diag")
self.detail_command_preview.setReadOnly(True)
# Read-only QPlainTextEdit never interprets HTML, regardless of what
# a compromised/careless catalog entry's command/args contain -- this
# is the field that renders "the exact bytes that will be written".
rv.addWidget(self.detail_command_preview, 1)
self.detail_action_btn = QPushButton("")
self.detail_action_btn.setObjectName("primary")
self.detail_action_btn.clicked.connect(self._on_action)
rv.addWidget(self.detail_action_btn)
splitter.addWidget(right)
splitter.setSizes([360, 480])
outer.addWidget(splitter, 1)
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
btns.rejected.connect(self.reject)
outer.addWidget(btns)
self._refresh_list()
# --- list / filtering -------------------------------------------------- #
def _set_group(self, group: str):
self._current_group = group
self._refresh_list()
def _visible_entries(self) -> list[dict]:
filtered = core.filter_catalog_entries(self.entries, self.search_box.text())
return core.catalog_entries_in_group(filtered, self._current_group)
def _format_row(self, entry: dict) -> str:
display = str(entry.get("display") or entry.get("id") or "")
official = "" if entry.get("official") else ""
stars = entry.get("stars")
star_txt = f"{stars:,}" if isinstance(stars, int) else ""
group = core.catalog_category_group(entry.get("category", ""))
desc = str(entry.get("description") or "")
if len(desc) > 88:
desc = desc[:87] + ""
# QListWidgetItem text is always rendered literally by Qt (no HTML
# interpretation), so no escaping is needed here -- unlike QLabel.
return f"{official}{display}{star_txt}\n{desc} · {group}"
def _refresh_list(self):
self.list.blockSignals(True)
self.list.clear()
for entry in self._visible_entries():
item = QListWidgetItem(self._format_row(entry))
item.setData(Qt.ItemDataRole.UserRole, entry)
# Tooltips DO auto-detect rich text in Qt, so escape defensively
# even though descriptions are already shown, unescaped-but-safe,
# in the QListWidgetItem text above.
item.setToolTip(html.escape(str(entry.get("description", ""))))
self.list.addItem(item)
self.list.blockSignals(False)
if self.list.count():
self.list.setCurrentRow(0)
else:
self._on_selected(None, None)
# --- detail pane -------------------------------------------------------- #
def _on_selected(self, current, _previous=None):
if current is None:
self._current_entry = None
self._current_homepage = None
self._current_docs_url = None
self.detail_title.setText("")
self.detail_meta.setText("")
self.detail_freshness.setText("")
self.detail_desc.setText("No matching servers." if self.entries else "")
self.detail_notes.setText("")
self.detail_homepage_btn.setVisible(False)
self.detail_command_preview.setPlainText("")
self.detail_action_btn.setEnabled(False)
self.detail_action_btn.setText("Add")
return
entry = current.data(Qt.ItemDataRole.UserRole)
self._current_entry = entry
self.detail_title.setText(str(entry.get("display") or entry.get("id") or ""))
official = "✓ Official" if entry.get("official") else ""
stars = entry.get("stars")
star_txt = f"{stars:,}" if isinstance(stars, int) else ""
group = core.catalog_category_group(entry.get("category", ""))
meta_bits = [b for b in (official, star_txt, group) if b]
self.detail_meta.setText(" · ".join(meta_bits))
freshness = core.format_freshness_hint(entry.get("last_release"))
self.detail_freshness.setText(freshness)
self.detail_freshness.setVisible(bool(freshness))
self.detail_desc.setText(str(entry.get("description") or ""))
notes = entry.get("notes") or ""
self.detail_notes.setText(notes)
self.detail_notes.setVisible(bool(notes))
homepage = entry.get("homepage")
self._current_homepage = homepage if isinstance(homepage, str) else None
self.detail_homepage_btn.setVisible(bool(self._current_homepage))
self._current_docs_url = (
entry.get("docs_url") if isinstance(entry.get("docs_url"), str) else None
)
self.detail_command_preview.setPlainText(self._render_command_preview(entry))
if entry.get("setup") == "basic":
self.detail_action_btn.setText("Add")
self.detail_action_btn.setEnabled(True)
else:
self.detail_action_btn.setText("Open setup docs")
self.detail_action_btn.setEnabled(bool(self._current_docs_url))
def _render_command_preview(self, entry: dict) -> str:
"""
The exact command that will be written, rendered verbatim. Every
value here comes straight from the (signature-verified) catalog
entry with no interpretation beyond str() -- this must never be the
place a markup-laced description sneaks back in as "helpful"
formatting.
"""
if entry.get("setup") != "basic":
docs = entry.get("docs_url") or "(none provided)"
return (
"This is a hosted/managed integration -- there is no local "
"command to add.\n\nSetup docs:\n " + str(docs)
)
config = entry.get("config") or {}
lines = [f"command: {config.get('command', '')}"]
args = config.get("args") or []
if args:
lines.append("args:")
lines.extend(f" {a}" for a in args)
env = config.get("env") or {}
env_required = entry.get("env_required") or {}
env_keys = list(env.keys()) + [k for k in env_required if k not in env]
if env_keys:
lines.append("env (names only -- you provide the values):")
lines.extend(f" {k}" for k in env_keys)
return "\n".join(lines)
def _open_homepage(self):
url = self._current_homepage
if url and url.startswith("https://"):
QDesktopServices.openUrl(QUrl(url))
def _on_action(self):
entry = self._current_entry
if not entry:
return
if entry.get("setup") == "basic":
self.result_entry = entry
self.accept()
else:
url = self._current_docs_url
if url and url.startswith("https://"):
QDesktopServices.openUrl(QUrl(url))
# link-only never auto-adds and never closes the dialog -- the
# user can keep browsing after opening the docs in their browser.
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Log viewer dialog (issue #6): a read-only, auto-tailing view of a single # Log viewer dialog (issue #6): a read-only, auto-tailing view of a single
# server's MCP log file. Polls on a QTimer instead of watching the filesystem # server's MCP log file. Polls on a QTimer instead of watching the filesystem
@@ -1780,6 +2092,10 @@ class MainWindow(QMainWindow):
self.del_btn = QPushButton("Delete") self.del_btn = QPushButton("Delete")
self.del_btn.setObjectName("danger") self.del_btn.setObjectName("danger")
self.paste_btn = QPushButton("Paste JSON...") self.paste_btn = QPushButton("Paste JSON...")
self.browse_catalog_btn = QPushButton("Browse catalog…")
self.browse_catalog_btn.setToolTip(
"Add a popular MCP server from the curated, signed catalog"
)
self.copy_btn = QPushButton("Copy to ▸") self.copy_btn = QPushButton("Copy to ▸")
self.undo_btn = QPushButton("Undo") self.undo_btn = QPushButton("Undo")
self.undo_btn.setEnabled(False) self.undo_btn.setEnabled(False)
@@ -1792,6 +2108,7 @@ class MainWindow(QMainWindow):
self.dup_btn.clicked.connect(self.duplicate_server) self.dup_btn.clicked.connect(self.duplicate_server)
self.del_btn.clicked.connect(self.delete_server) self.del_btn.clicked.connect(self.delete_server)
self.paste_btn.clicked.connect(self.paste_json) self.paste_btn.clicked.connect(self.paste_json)
self.browse_catalog_btn.clicked.connect(self.browse_catalog)
self.copy_btn.clicked.connect(self.copy_to_menu) self.copy_btn.clicked.connect(self.copy_to_menu)
self.undo_btn.clicked.connect(self._undo) self.undo_btn.clicked.connect(self._undo)
self.test_all_btn.clicked.connect(self._test_all_servers) self.test_all_btn.clicked.connect(self._test_all_servers)
@@ -1800,6 +2117,7 @@ class MainWindow(QMainWindow):
self.dup_btn, self.dup_btn,
self.del_btn, self.del_btn,
self.paste_btn, self.paste_btn,
self.browse_catalog_btn,
self.copy_btn, self.copy_btn,
self.undo_btn, self.undo_btn,
self.test_all_btn, self.test_all_btn,
@@ -2378,6 +2696,41 @@ class MainWindow(QMainWindow):
self._mark_dirty() self._mark_dirty()
self.status.setText(f"Imported {added} added, {replaced} replaced. Review and Save.") self.status.setText(f"Imported {added} added, {replaced} replaced. Review and Save.")
def browse_catalog(self):
"""
Open the Browse-catalog dialog (issue #10 phase 2). The catalog is
loaded and signature-verified fresh every time the dialog opens --
never cached across app runs at this phase (remote fetch/cache is
#61, not yet built) -- so a bundled-catalog swap only takes effect
on next dialog open, never mid-session in a stale way.
"""
entries = core.load_bundled_catalog_entries(
_asset_dir() / "data" / "catalog.json", _asset_dir() / "data" / "catalog.json.sig"
)
dlg = BrowseCatalogDialog(self, entries)
if dlg.exec() != QDialog.DialogCode.Accepted or not dlg.result_entry:
return
entry = dlg.result_entry
paste = core.catalog_entry_to_paste_json(entry)
name, data = next(iter(paste.items()))
existing_before = {s.name: i for i, s in enumerate(self.servers)}
self._push_undo()
_added, replaced = self._import_server(name, data)
idx = (
existing_before.get(name, len(self.servers) - 1) if replaced else len(self.servers) - 1
)
self._refresh_tables(select_index=idx)
self._mark_dirty()
target = core.first_unfilled_focus_target(data)
self.editor.focus_target(target)
verb = "Replaced" if replaced else "Added"
self.status.setText(
f"{verb}{name}” from the catalog. Fill in the highlighted field and Save."
)
def copy_to_menu(self): def copy_to_menu(self):
idx = self._current_index() idx = self._current_index()
if not (0 <= idx < len(self.servers)): if not (0 <= idx < len(self.servers)):
@@ -2456,6 +2809,29 @@ class MainWindow(QMainWindow):
QMessageBox.warning(self, "Can't save yet", "Fix the highlighted problem first.") QMessageBox.warning(self, "Can't save yet", "Fix the highlighted problem first.")
return return
# Placeholder guard (issue #10): a catalog Add can leave a
# <PLACEHOLDER>-style token in args/env until the user fills it in.
# This warns, it does not block -- the user may be deliberately
# saving a stub to finish later -- but it must never save silently,
# since a server launched with a literal "<PLACEHOLDER>" argument
# just fails in a confusing way at spawn time.
placeholder_names = [
s.name for s in self.servers if core.config_has_unfilled_placeholders(s.data)
]
if placeholder_names:
names = ", ".join(f"{n}" for n in placeholder_names)
ans = QMessageBox.warning(
self,
"Unfilled placeholder",
f"{names} still has a <PLACEHOLDER> value that hasn't been "
"replaced with a real value. Claude won't be able to use "
"it as-is.\n\nSave anyway?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if ans != QMessageBox.StandardButton.Yes:
return
# Stale-file check: if the file changed on disk since we loaded it, prompt. # Stale-file check: if the file changed on disk since we loaded it, prompt.
# Compare mtime AND size (not mtime alone) so a concurrent external write # Compare mtime AND size (not mtime alone) so a concurrent external write
# that lands within the mtime resolution window, or that restores the # that lands within the mtime resolution window, or that restores the
+5 -1
View File
@@ -31,7 +31,11 @@ a = Analysis(
["bcc.py"], ["bcc.py"],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[("icons", "icons")], datas=[
("icons", "icons"),
("data/catalog.json", "data"),
("data/catalog.json.sig", "data"),
],
hiddenimports=[], hiddenimports=[],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
+596
View File
@@ -13,6 +13,7 @@ in its original position.
from __future__ import annotations from __future__ import annotations
import base64
import contextlib import contextlib
import difflib import difflib
import functools import functools
@@ -28,10 +29,14 @@ import tempfile
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date
from pathlib import Path from pathlib import Path
from typing import NamedTuple from typing import NamedTuple
from urllib.parse import urlparse from urllib.parse import urlparse
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
CONFIG_FILENAME = "claude_desktop_config.json" CONFIG_FILENAME = "claude_desktop_config.json"
# Disabled servers are parked under this non-standard key. Claude Desktop only # Disabled servers are parked under this non-standard key. Claude Desktop only
@@ -2143,3 +2148,594 @@ def restart_claude_desktop() -> RestartResult:
if sys.platform.startswith("win"): if sys.platform.startswith("win"):
return _restart_claude_desktop_windows() return _restart_claude_desktop_windows()
return RestartResult(False, "Restarting Claude Desktop isn't supported on this platform.") return RestartResult(False, "Restarting Claude Desktop isn't supported on this platform.")
# --------------------------------------------------------------------------- #
# MCP server catalog (issue #10 / #61)
#
# A curated, SIGNED list of ready-to-use MCP server definitions (bundled with
# the app and, later, fetchable/cacheable — see follow-up issues). Every
# function here is pure and defensive: catalog bytes may come from a fetch
# over the network, a disk cache, or the copy frozen into the binary, and
# all three are treated as equally untrusted until their signature verifies.
# --------------------------------------------------------------------------- #
# Commands a catalog entry's config is allowed to launch. Anything else
# (bash, sh, curl, a raw script interpreter that isn't on this list, ...)
# is rejected by validate_catalog() regardless of how plausible it looks.
CATALOG_ALLOWED_COMMANDS = frozenset({"npx", "uvx", "docker", "node", "python", "python3"})
# Ed25519 public keys allowed to sign a catalog, raw 32-byte form. A LIST
# (not a single key) so keys can be rotated without bricking installs that
# still trust an older key: verify_catalog_signature() accepts a match
# against ANY key in this list.
CATALOG_PUBKEYS: list[bytes] = [
base64.b64decode("082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4="),
]
# Domain-separation prefix for the signed message. The signature covers
# this prefix + the raw catalog bytes, never the raw bytes alone, so a
# catalog signature can't be replayed against some other byte-for-byte-
# identical payload that means something else in a different context.
_CATALOG_SIG_DOMAIN = b"bcc-catalog-v1|"
# Top-level fields that must be https:// URLs when present.
_CATALOG_URL_FIELDS = ("homepage", "docs_url", "source")
# Inline secret-flag=value forms. Distinct from _TOKEN_PREFIXES below --
# this catches "--api-key=<real value>" even when the value itself doesn't
# match a well-known token prefix.
_CATALOG_SECRET_ARG_RE = re.compile(r"(?i)--api[-_]?key=|--token=|--password=")
# <PLACEHOLDER>-style tokens the GUI must have the user fill in before Save.
_PLACEHOLDER_RE = re.compile(r"<[^<>\s]+>")
# How many versions a single accepted catalog jump may leap in one go. Bounds
# a "freeze" attack: a compromised/leaked signing key claiming an absurd
# future version would otherwise permanently outrank every legitimate
# catalog release from then on, since the resolver always prefers the
# highest verified version.
_CATALOG_MAX_VERSION_JUMP = 1000
def load_catalog(raw: bytes | str) -> dict:
"""
Parse catalog bytes/text into a dict using STRICT json.loads ONLY.
🔴 CRITICAL: the lenient JSON repair pipeline (repair_json_text,
parse_pasted_json / parse_pasted_json_verbose) must NEVER be wired in
here, or anywhere near catalog handling. That pipeline exists to be
forgiving of hand-pasted snippets from docs and blog posts smart
quotes, trailing commas, unquoted keys, whatever a human fat-fingered.
Forgiveness is exactly the property a signed payload cannot have:
verify_catalog_signature() authenticates the exact bytes that were
signed. If what gets displayed/executed is a "repaired" reinterpretation
of those bytes rather than the bytes themselves, the signature check
still passes while guaranteeing nothing about what actually runs. Always
verify raw bytes, then load_catalog() those SAME raw bytes.
"""
return json.loads(raw)
def catalog_version(data: dict) -> int:
"""Extract the integer version from a parsed catalog dict (0 if absent/bad)."""
version = data.get("version") if isinstance(data, dict) else None
return version if isinstance(version, int) and not isinstance(version, bool) else 0
def _secret_looking_arg(a: str) -> bool:
"""
True when a catalog arg string looks like it embeds a real secret. Reuses
the existing token-prefix detector (_is_secret_value / _TOKEN_PREFIXES)
rather than reimplementing it one definition of "looks like a secret"
for the whole app.
"""
if _CATALOG_SECRET_ARG_RE.search(a):
return True
value = a.split("=", 1)[1] if "=" in a else a
return _is_secret_value(value) or _is_secret_value(a)
def _docker_arg_violations(tag: str, args: list[str]) -> list[str]:
"""--privileged and volume mounts rooted at / or $HOME are refused."""
problems: list[str] = []
if "--privileged" in args:
problems.append(f"{tag}: config.args uses --privileged, which is not allowed.")
i = 0
while i < len(args):
a = args[i]
mount = None
if a in ("-v", "--volume") and i + 1 < len(args):
mount = args[i + 1]
i += 1
elif a.startswith("--volume="):
mount = a.split("=", 1)[1]
elif a.startswith("-v") and a != "-v":
mount = a[2:]
if mount:
source = mount.split(":", 1)[0]
if source in ("/", "$HOME") or source.startswith("$HOME"):
problems.append(
f"{tag}: config.args mounts {source!r}, which is not allowed "
"(volume mounts of / or $HOME are refused)."
)
i += 1
return problems
def _validate_catalog_config(tag: str, config) -> list[str]:
"""Validate the `config` block of a basic-tier catalog entry."""
if not isinstance(config, dict):
return [f"{tag}: basic entries require a 'config' object with command+args."]
problems: list[str] = []
command = config.get("command")
if not isinstance(command, str) or not command:
problems.append(f"{tag}: config.command must be a non-empty string.")
command = ""
elif not command.isascii():
problems.append(f"{tag}: config.command must be ASCII (non-ASCII code points rejected).")
if command and command not in CATALOG_ALLOWED_COMMANDS:
problems.append(
f"{tag}: config.command {command!r} is not on the catalog allowlist "
f"({', '.join(sorted(CATALOG_ALLOWED_COMMANDS))})."
)
args = config.get("args")
if not isinstance(args, list) or not all(isinstance(a, str) for a in args):
problems.append(f"{tag}: config.args must be a list of strings.")
args = []
for a in args:
if not a.isascii():
problems.append(f"{tag}: config.args contains a non-ASCII value ({a!r}).")
if _secret_looking_arg(a):
problems.append(
f"{tag}: config.args contains a secret-looking value ({a!r}); "
"secrets belong in env, never args."
)
if command in ("node", "python", "python3") and any(a in ("-e", "--eval", "-c") for a in args):
problems.append(
f"{tag}: config.args uses -e/--eval/-c with {command!r}, which is not allowed."
)
if command == "docker":
problems.extend(_docker_arg_violations(tag, args))
env = config.get("env")
if env is not None and (
not isinstance(env, dict) or any(not isinstance(v, str) for v in env.values())
):
problems.append(f"{tag}: config.env must be an object of string values.")
return problems
def _validate_catalog_entry(idx: int, entry, seen_ids: set[str]) -> list[str]:
"""Validate a single `servers[idx]` catalog entry."""
tag = f"servers[{idx}]"
if not isinstance(entry, dict):
return [f"{tag}: must be an object."]
problems: list[str] = []
entry_id = entry.get("id")
if not isinstance(entry_id, str) or not entry_id.strip():
problems.append(f"{tag}: 'id' must be a non-empty string.")
else:
tag = f"servers[{idx}] ({entry_id!r})"
if not entry_id.isascii():
problems.append(f"{tag}: 'id' must be ASCII (non-ASCII code points rejected).")
if entry_id in seen_ids:
problems.append(f"{tag}: duplicate id.")
seen_ids.add(entry_id)
for field in ("display", "description", "category"):
if not isinstance(entry.get(field), str) or not entry[field].strip():
problems.append(f"{tag}: '{field}' must be a non-empty string.")
if not isinstance(entry.get("official"), bool):
problems.append(f"{tag}: 'official' must be a boolean.")
setup = entry.get("setup")
if setup not in ("basic", "link-only"):
problems.append(f"{tag}: 'setup' must be 'basic' or 'link-only'.")
env_required = entry.get("env_required")
if not isinstance(env_required, dict):
problems.append(f"{tag}: 'env_required' must be an object.")
else:
for k, v in env_required.items():
if not isinstance(k, str):
problems.append(f"{tag}: 'env_required' keys must be strings.")
if v != "":
problems.append(
f"{tag}: env_required[{k!r}] must be an empty string — "
"catalog entries never ship secret values, only the names "
"of env vars the user must fill in."
)
for field in _CATALOG_URL_FIELDS:
if field in entry and entry[field] is not None:
url = entry[field]
if not isinstance(url, str) or not url.startswith("https://"):
problems.append(f"{tag}: '{field}' must be an https:// URL.")
if setup == "link-only":
if entry.get("config") is not None:
problems.append(f"{tag}: link-only entries must not have a 'config'.")
docs_url = entry.get("docs_url")
if not isinstance(docs_url, str) or not docs_url.startswith("https://"):
problems.append(f"{tag}: link-only entries require an https:// 'docs_url'.")
elif setup == "basic":
problems.extend(_validate_catalog_config(tag, entry.get("config")))
return problems
def validate_catalog(data) -> list[str]:
"""
Validate a parsed catalog dict. Returns a list of human-readable
problems; an EMPTY list means the catalog is valid.
A non-empty list means REJECT THE WHOLE FILE, not just the offending
entry. There is no per-entry salvage here: a catalog that is invalid in
one place is untrusted everywhere, because a caller that tried to keep
"the other 19 entries that looked fine" would need its own judgment call
about which parts of a failed-validation file to trust exactly the
judgment call this function exists to make once, centrally.
"""
if not isinstance(data, dict):
return ["Catalog root must be a JSON object."]
problems: list[str] = []
schema = data.get("schema")
if not isinstance(schema, int) or isinstance(schema, bool) or schema < 1:
problems.append("'schema' must be a positive integer.")
version = data.get("version")
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
problems.append("'version' must be a positive integer.")
servers = data.get("servers")
if not isinstance(servers, list):
problems.append("'servers' must be a list.")
return problems # nothing else to check without a server list
seen_ids: set[str] = set()
for idx, entry in enumerate(servers):
problems.extend(_validate_catalog_entry(idx, entry, seen_ids))
return problems
def verify_catalog_signature(raw: bytes, sig: bytes, pubkeys: list[bytes]) -> bool:
"""
Verify an Ed25519 signature over `raw` catalog bytes.
The signed message is domain-separated: b"bcc-catalog-v1|" + raw, not
raw alone (see _CATALOG_SIG_DOMAIN).
Returns True if ANY key in `pubkeys` verifies this is what lets keys
rotate without bricking installs still trusting an older key.
Never raises. An invalid signature, a garbage/wrong-length key, a
non-bytes argument, an empty signature all of it just returns False.
Signature verification is exactly the wrong place for an exception to
accidentally propagate into a code path that fails open.
"""
if not isinstance(raw, bytes) or not isinstance(sig, (bytes, bytearray)):
return False
if not sig:
return False
message = _CATALOG_SIG_DOMAIN + raw
for pk in pubkeys or []:
try:
Ed25519PublicKey.from_public_bytes(bytes(pk)).verify(bytes(sig), message)
return True
except (InvalidSignature, ValueError, TypeError):
continue
return False
def resolve_catalog(
bundled: tuple[bytes, bytes] | None,
cached: tuple[bytes, bytes] | None,
remote: tuple[bytes, bytes] | None,
) -> dict:
"""
Pick the highest-version catalog among bundled/cached/remote. Each
argument is either None (unavailable) or an (raw_bytes, signature_bytes)
pair.
🔴 SECURITY: every candidate including `bundled`, the copy frozen into
this binary is verified against CATALOG_PUBKEYS and re-validated from
scratch right here. The bundled catalog gets NO implicit trust. This was
a hole in the original design: bundling data/catalog.json as a plain
asset would let an unsigned/malformed payload that somehow merged to
main ship inside the next release and win the version comparison simply
by virtue of being local. Signing (and checking the signature at
runtime, every time) closes that.
Anti-rollback: a candidate's version is never accepted if it's lower
than the best verified candidate already found in this same resolution
pass an attacker replaying an old, since-superseded signed catalog
can't downgrade you.
Anti-freeze: a candidate whose version leaps more than
_CATALOG_MAX_VERSION_JUMP past the current best is also rejected. A
compromised/leaked signing key claiming an absurd future version would
otherwise permanently outrank every legitimate release from then on,
since the resolver always prefers the highest verified version this
caps how far a single accepted jump can go.
Returns the winning catalog dict, or {} if nothing verified and
validated.
"""
best: dict = {}
best_version = -1
for candidate in (bundled, cached, remote):
if not candidate:
continue
raw, sig = candidate
if not verify_catalog_signature(raw, sig, CATALOG_PUBKEYS):
continue
try:
data = load_catalog(raw)
except (ValueError, TypeError):
continue
if validate_catalog(data):
continue
version = catalog_version(data)
if best_version >= 0:
if version < best_version:
continue # anti-rollback
if version > best_version + _CATALOG_MAX_VERSION_JUMP:
continue # anti-freeze
best = data
best_version = version
return best
def catalog_entry_to_paste_json(entry: dict) -> dict:
"""
Convert a basic-tier catalog entry into the {name: {command, args, env}}
shape parse_pasted_json()/_import_server() already understand, so the
Browse-catalog dialog can feed a selection straight into the existing
paste-import path instead of growing a parallel one.
`env` is seeded from two sources: config.env (rare -- e.g. grafana's
non-secret GRAFANA_URL) and, for every key in `env_required` not already
present, an empty-string placeholder. env_required is where the seed
data actually keeps its secret VAR NAMES (validate_catalog requires its
values to be "" -- never a real secret); config.env alone, without this,
would silently drop those names on Add for the 9 of 19 seed entries that
need a secret and only declare it via env_required -- the user would
see a server added with no field prompting them for the key it needs.
"""
config = entry.get("config") or {}
name = entry.get("id") or entry.get("display") or "server"
data: dict = {
"command": config.get("command", ""),
"args": list(config.get("args") or []),
}
env = dict(config.get("env") or {})
for key in entry.get("env_required") or {}:
env.setdefault(key, "")
if env:
data["env"] = env
return {str(name): data}
def config_has_unfilled_placeholders(cfg: dict) -> bool:
"""
True if any <PLACEHOLDER>-style token remains anywhere in a server
config's command/args/env (the shape produced by
catalog_entry_to_paste_json). The GUI uses this to refuse Save until
every <ALLOWED_DIR>-style token has been filled in with a real value.
"""
values: list[str] = []
cmd = cfg.get("command")
if isinstance(cmd, str):
values.append(cmd)
values.extend(a for a in (cfg.get("args") or []) if isinstance(a, str))
env = cfg.get("env") or {}
if isinstance(env, dict):
values.extend(v for v in env.values() if isinstance(v, str))
return any(_PLACEHOLDER_RE.search(v) for v in values)
# --------------------------------------------------------------------------- #
# Catalog: Browse-dialog helpers (issue #10 phase 2)
#
# Everything below is pure and GUI-free on purpose (per the design comment on
# #10): the dialog itself should be a thin shell that calls into this module,
# the same relationship bcc.py already has with the rest of bcc_core.py.
# --------------------------------------------------------------------------- #
# Collapses the 20-value category taxonomy from the catalog research pass
# down to the 7 chips shown in the Browse dialog. Any category not listed
# here (including one a future catalog entry introduces that we don't yet
# know about) falls back to "Other" rather than raising -- an unrecognized
# category must never make an entry disappear from the dialog.
CATALOG_CATEGORY_GROUPS: dict[str, str] = {
"files": "Files & Dev",
"dev": "Files & Dev",
"code-hosting": "Files & Dev",
"browser": "Files & Dev",
"database": "Data",
"data": "Data",
"search": "Search & AI",
"ai": "Search & AI",
"cloud": "Cloud & Infra",
"infra": "Cloud & Infra",
"observability": "Cloud & Infra",
"productivity": "Work",
"communication": "Work",
"crm": "Work",
"finance": "Work",
"design": "Work",
"media": "Home & Personal",
"smart-home": "Home & Personal",
"personal": "Home & Personal",
}
# Ordered for chip display: "All" first, the 6 named groups next in the order
# given in the #10 design comment, "Other" last as the catch-all.
CATALOG_CATEGORY_CHIPS: tuple[str, ...] = (
"All",
"Files & Dev",
"Data",
"Search & AI",
"Cloud & Infra",
"Work",
"Home & Personal",
"Other",
)
def catalog_category_group(category: str) -> str:
"""Collapse a raw catalog `category` value to one of the 7 UI chips.
Unknown/missing categories map to "Other" -- never raises, never drops
an entry from the list just because its category tag doesn't match one
of the ones known at the time this mapping was written.
"""
return CATALOG_CATEGORY_GROUPS.get(str(category or "").strip().lower(), "Other")
def catalog_entry_matches_query(entry: dict, query: str) -> bool:
"""
Case-insensitive substring match against a catalog entry's id, display
name, description, and category. An empty/whitespace-only query matches
everything, so the search box doubles as "no filter" when cleared --
the same convention server_matches_filter() uses for the main table.
"""
q = (query or "").strip().lower()
if not q:
return True
haystacks = (
str(entry.get("id", "")),
str(entry.get("display", "")),
str(entry.get("description", "")),
str(entry.get("category", "")),
)
return any(q in h.lower() for h in haystacks)
def filter_catalog_entries(entries: list[dict], query: str) -> list[dict]:
"""Return only the catalog entries that match `query` (see
catalog_entry_matches_query)."""
return [e for e in entries if catalog_entry_matches_query(e, query)]
def catalog_entries_in_group(entries: list[dict], group: str) -> list[dict]:
"""
Return only the entries whose category collapses into `group` (one of
CATALOG_CATEGORY_CHIPS). "All" (or a falsy/unrecognized group) returns
every entry unfiltered -- that's the default chip state.
"""
if not group or group == "All":
return list(entries)
return [e for e in entries if catalog_category_group(e.get("category", "")) == group]
def format_freshness_hint(last_release: str | None, today: date | None = None) -> str:
"""
Turn a catalog entry's `last_release` (an ISO "YYYY-MM-DD" date, or None
when the registry didn't expose one) into a short freshness hint for the
detail pane, e.g. "Last updated 14 months ago".
Returns "" (nothing to show) when `last_release` is missing or
unparseable, or when it's somehow in the future relative to `today` --
a bogus "-3 months ago" would undermine the one signal this hint exists
to give the user, so we'd rather show nothing than something wrong.
`today` is an injectable override so this is exactly reproducible in
tests without depending on the wall clock.
"""
if not last_release or not isinstance(last_release, str):
return ""
try:
released = date.fromisoformat(last_release)
except ValueError:
return ""
now = today or date.today()
if released > now:
return ""
months = (now.year - released.year) * 12 + (now.month - released.month)
if now.day < released.day:
months -= 1
months = max(months, 0)
if months == 0:
return "Last updated this month"
if months == 1:
return "Last updated 1 month ago"
if months < 24:
return f"Last updated {months} months ago"
years = months // 12
return f"Last updated {years} year{'s' if years != 1 else ''} ago"
def first_unfilled_focus_target(data: dict) -> tuple[str, int | str] | None:
"""
Given a server config dict shaped like catalog_entry_to_paste_json()'s
output (command/args/env), find the first thing a user must fill in
after a catalog Add: a <PLACEHOLDER>-style arg (checked first, since a
missing path/target usually blocks the server from starting at all) or
else the first env var the catalog left blank.
Returns ("args", index) or ("env", key), or None when there's nothing
left to fill (e.g. a server with no placeholders and no required env).
The GUI uses this to focus+select the right field right after Add,
instead of leaving the user to hunt for what still needs a value.
"""
args = data.get("args") or []
for i, a in enumerate(args):
if isinstance(a, str) and _PLACEHOLDER_RE.search(a):
return ("args", i)
env = data.get("env") or {}
if isinstance(env, dict):
for k, v in env.items():
if not isinstance(v, str) or not v.strip() or _PLACEHOLDER_RE.search(v):
return ("env", k)
return None
def load_bundled_catalog_entries(catalog_path: Path, sig_path: Path) -> list[dict]:
"""
Read+verify+validate the bundled catalog.json/.sig pair from disk and
return its `servers` list -- or an EMPTY list if anything at all is
wrong: files missing/unreadable, signature doesn't verify, JSON doesn't
parse, or validate_catalog() finds a problem.
🔴 SECURITY: this is the load-bearing guarantee for the Browse dialog.
There is deliberately no partial-success path here -- a signature
failure must never surface a half-trusted list, only an empty one, so
the GUI's only job is to render "Catalog unavailable" when this comes
back empty. All the real trust decisions (signature, schema, command
allowlist) already live in resolve_catalog()/validate_catalog(); this
is a thin disk-reading wrapper around them so the GUI never touches
catalog bytes directly.
"""
try:
raw = catalog_path.read_bytes()
sig = sig_path.read_bytes()
except OSError:
return []
data = resolve_catalog(bundled=(raw, sig), cached=None, remote=None)
servers = data.get("servers") if isinstance(data, dict) else None
return servers if isinstance(servers, list) else []
+839
View File
@@ -0,0 +1,839 @@
"""
catalog_console.py -- Catalog Console: maintainer-only review + signing tool
for data/catalog.json (issue #62).
MAINTAINER-ONLY. Run from a source checkout. NEVER shipped to users and
NEVER included in the release bundle -- see bcc.spec (Analysis only ever
starts from bcc.py) and tests/test_packaging.py, which asserts this file
and catalog_review.py are absent from the packaged bundle.
Flow: Load -> Review -> Sign.
1. Load -- pick a source: an open Gitea PR touching data/catalog.json,
or the current tip of `main`. The Console fetches the exact
git blob (via a local clone's git plumbing) and PINS its
blob SHA for the rest of this review pass.
2. Review -- a semantic diff (catalog_review.diff_catalogs), one card per
changed entry, with risk annotations
(catalog_review.entry_risk_findings). A registry lookup for
each entry's npm/PyPI package kicks off automatically, one
worker thread per entry, the moment the cards are built --
it is the one check a reviewer can't do by eye, so it must
never depend on a click. It fails soft (a dead registry
shows "unavailable", never blocks review or Sign) and a
per-card "Re-check" button covers manual retries. Every
changed entry must be individually acknowledged (its
checkbox ticked) before Sign unlocks. There is no
"acknowledge all" -- see catalog_review.py.
3. Sign -- re-fetches the current blob SHA and refuses to sign unless
it still matches the pinned SHA from step 1 (TOCTOU fix:
catalog_review.can_sign). On success, writes
data/catalog.json + data/catalog.json.sig and commits BOTH
in a single commit, then pushes -- so main is never red
between a catalog merge and its signature.
The signature must be the artefact of an actual review, not a step that
follows one. Signing IS the approval act.
"""
from __future__ import annotations
import argparse
import contextlib
import getpass
import html
import json
import re
import subprocess
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
import bcc_core as core
import catalog_review as review
# --------------------------------------------------------------------------- #
# Constants
# --------------------------------------------------------------------------- #
GITEA_HOST = "git.avezzano.io"
GITEA_API_BASE = f"https://{GITEA_HOST}/api/v1"
REPO_OWNER = "the_og"
REPO_NAME = "better-claude-config"
CATALOG_PATH = "data/catalog.json"
SIG_PATH = "data/catalog.json.sig"
# Outside the repo, per issue #62 ("never committed, never plaintext"). A
# maintainer-only tool, so a dotfile under $HOME is an acceptable fallback
# when the OS keychain isn't available -- the blob stored there is always
# passphrase-encrypted (see catalog_review.encrypt_private_key), never raw.
KEY_STORAGE_DIR = Path.home() / ".bcc-catalog-console"
KEY_STORAGE_FILE = KEY_STORAGE_DIR / "signing_key.enc"
HTTP_TIMEOUT = 6.0
# --------------------------------------------------------------------------- #
# Key storage: OS keychain if available, else a passphrase-encrypted file
# outside the repo. Never plaintext, never an env var, never committed.
# --------------------------------------------------------------------------- #
def _keyring_module():
"""Best-effort import of the optional `keyring` package. Returns None if
it isn't installed -- this tool must work without it, falling back to
the encrypted-file path. `keyring` is deliberately NOT added to
requirements-dev.txt: this is a maintainer-only tool excluded from the
shipped app, so it doesn't need to justify a new runtime dependency for
every user the way bcc.py's dependencies do."""
try:
import keyring
return keyring
except ImportError:
return None
_KEYRING_SERVICE = "bcc-catalog-console"
_KEYRING_USERNAME = "signing-key"
def store_encrypted_key(blob: bytes) -> str:
"""Persist an already-encrypted key blob (see
catalog_review.encrypt_private_key). Prefers the OS keychain; falls back
to a file under KEY_STORAGE_DIR (outside the repo) with restrictive
permissions. Returns a human-readable description of where it went."""
keyring = _keyring_module()
if keyring is not None:
try:
keyring.set_password(_KEYRING_SERVICE, _KEYRING_USERNAME, blob.hex())
return "OS keychain (via the `keyring` package)"
except Exception:
pass # fall through to the file-based path
KEY_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
KEY_STORAGE_FILE.write_bytes(blob)
with contextlib.suppress(OSError): # best-effort on platforms without POSIX perm bits
KEY_STORAGE_FILE.chmod(0o600)
return f"encrypted file at {KEY_STORAGE_FILE}"
def load_encrypted_key() -> bytes:
"""Load the encrypted key blob from wherever store_encrypted_key() put
it. Raises FileNotFoundError if no key has been generated yet."""
keyring = _keyring_module()
if keyring is not None:
try:
hex_blob = keyring.get_password(_KEYRING_SERVICE, _KEYRING_USERNAME)
if hex_blob:
return bytes.fromhex(hex_blob)
except Exception:
pass
if not KEY_STORAGE_FILE.exists():
raise FileNotFoundError(
f"No signing key found (checked the OS keychain and {KEY_STORAGE_FILE}). "
"Run `python catalog_console.py keygen` first."
)
return KEY_STORAGE_FILE.read_bytes()
def unlock_signing_key(passphrase: str) -> bytes:
"""Load + decrypt the signing key seed. Raises ValueError on a wrong
passphrase, FileNotFoundError if no key exists yet."""
blob = load_encrypted_key()
return review.decrypt_private_key(blob, passphrase)
# --------------------------------------------------------------------------- #
# git plumbing against a local clone. The clone's `origin` remote is assumed
# to already carry credentials (the "tokened remote" every other BCC
# maintainer script relies on) -- this module never handles a token itself.
# --------------------------------------------------------------------------- #
class GitError(RuntimeError):
pass
def _git(repo_dir: Path, *args: str, capture_bytes: bool = False):
cmd = ["git", "-C", str(repo_dir), *args]
result = subprocess.run(cmd, capture_output=True, check=False)
if result.returncode != 0:
stderr = result.stderr.decode("utf-8", "replace")
raise GitError(f"git {' '.join(args)} failed: {stderr}")
return result.stdout if capture_bytes else result.stdout.decode("utf-8", "replace")
def fetch_ref(repo_dir: Path, ref: str) -> str:
"""Fetch `ref` from origin and return the resulting commit SHA."""
_git(repo_dir, "fetch", "origin", ref)
return _git(repo_dir, "rev-parse", "FETCH_HEAD").strip()
def blob_sha_at(repo_dir: Path, commit: str, path: str) -> str:
"""The git blob SHA of `path` as it exists at `commit`. This is what
gets pinned at review-start and re-checked immediately before signing
(catalog_review.can_sign) -- the TOCTOU fix."""
return _git(repo_dir, "rev-parse", f"{commit}:{path}").strip()
def blob_bytes(repo_dir: Path, blob_sha: str) -> bytes:
return _git(repo_dir, "cat-file", "blob", blob_sha, capture_bytes=True)
def read_catalog_at_commit(repo_dir: Path, commit: str) -> tuple[bytes, str]:
"""Return (raw_bytes, blob_sha) for data/catalog.json at `commit`."""
sha = blob_sha_at(repo_dir, commit, CATALOG_PATH)
return blob_bytes(repo_dir, sha), sha
def commit_and_push_signed_catalog(
repo_dir: Path, raw_bytes: bytes, signature: bytes, *, branch: str = "main"
) -> str:
"""Write data/catalog.json + data/catalog.json.sig and commit BOTH in a
single commit, then push to `branch`. Returns the new commit SHA.
This is deliberate: if signing happened in a commit AFTER the catalog
merge, main would be red (payload present, signature missing) between
every catalog merge and its signing commit. Routine red-main trains
exactly the alarm fatigue this whole design exists to prevent. Emitting
one commit with both files means main is never in that state.
"""
_git(repo_dir, "checkout", branch)
_git(repo_dir, "pull", "--ff-only", "origin", branch)
(repo_dir / CATALOG_PATH).write_bytes(raw_bytes)
(repo_dir / SIG_PATH).write_bytes(signature)
_git(repo_dir, "add", CATALOG_PATH, SIG_PATH)
_git(
repo_dir,
"commit",
"-m",
"chore: sign data/catalog.json (Catalog Console, #62)\n\n"
"Payload and detached Ed25519 signature land together so main is "
"never red between a catalog merge and its signature.",
)
_git(repo_dir, "push", "origin", branch)
return _git(repo_dir, "rev-parse", "HEAD").strip()
# --------------------------------------------------------------------------- #
# Gitea REST API: list open PRs touching data/catalog.json
# --------------------------------------------------------------------------- #
def _gitea_get(path: str, token: str | None = None) -> object:
url = f"{GITEA_API_BASE}{path}"
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"token {token}")
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
return json.loads(resp.read().decode("utf-8"))
@dataclass(frozen=True)
class CatalogPR:
number: int
title: str
head_ref: str # refs/pull/<n>/head
def list_open_catalog_prs(token: str | None = None) -> list[CatalogPR]:
"""Open PRs against REPO_OWNER/REPO_NAME whose diff touches
data/catalog.json. Fails soft: on any network error, returns [] rather
than raising into the GUI (Load still offers the `main` source)."""
try:
prs = _gitea_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/pulls?state=open", token)
except (urllib.error.URLError, TimeoutError, ValueError):
return []
matches: list[CatalogPR] = []
for pr in prs or []:
number = pr.get("number")
if not isinstance(number, int):
continue
if _pr_touches_catalog(number, token):
matches.append(
CatalogPR(
number=number,
title=str(pr.get("title", f"PR #{number}")),
head_ref=f"refs/pull/{number}/head",
)
)
return matches
def _pr_touches_catalog(pr_number: int, token: str | None) -> bool:
url = f"https://{GITEA_HOST}/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}.diff"
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
diff_text = resp.read().decode("utf-8", "replace")
except (urllib.error.URLError, TimeoutError):
return False
return CATALOG_PATH in diff_text
def token_from_git_remote(repo_dir: Path) -> str | None:
"""Best-effort extraction of a PAT embedded in `origin`'s URL
(https://<token>@host/...), matching the "tokened remote" every other
BCC maintainer flow already relies on. Returns None if there isn't one
(public read-only API calls still work, just rate-limited)."""
try:
url = _git(repo_dir, "remote", "get-url", "origin").strip()
except GitError:
return None
match = re.match(r"https://([^@/]+)@", url)
if not match:
return None
token = match.group(1)
# `user:token` form -- keep only the token half if present.
return token.split(":", 1)[-1]
# --------------------------------------------------------------------------- #
# Registry lookup fetchers (npm / PyPI). Kept out of catalog_review.py so the
# pure module never makes a network call itself -- these are injected as the
# `Fetcher` callable review.lookup_registry_info() expects.
# --------------------------------------------------------------------------- #
def fetch_npm_info(ref: review.PackageRef) -> dict | None:
url = f"https://registry.npmjs.org/{ref.name}"
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
data = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, ValueError):
return None
time_info = data.get("time") or {}
created = time_info.get("created")
modified = time_info.get("modified")
age_days = _iso_age_days(created)
maintainers = data.get("maintainers") or []
publisher = maintainers[0].get("name") if maintainers else None
downloads = None
try:
dl_url = f"https://api.npmjs.org/downloads/point/last-week/{ref.name}"
with urllib.request.urlopen(dl_url, timeout=HTTP_TIMEOUT) as resp:
downloads = json.loads(resp.read().decode("utf-8")).get("downloads")
except (urllib.error.URLError, TimeoutError, ValueError):
pass # fail soft -- downloads are a nice-to-have, not required
return {
"publisher": publisher,
"age_days": age_days,
"last_release": modified,
"downloads": downloads,
}
def fetch_pypi_info(ref: review.PackageRef) -> dict | None:
url = f"https://pypi.org/pypi/{ref.name}/json"
try:
with urllib.request.urlopen(url, timeout=HTTP_TIMEOUT) as resp:
data = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, ValueError):
return None
info = data.get("info") or {}
releases = data.get("releases") or {}
last_release = None
earliest_upload = None
for files in releases.values():
for f in files:
uploaded = f.get("upload_time_iso_8601")
if not uploaded:
continue
if last_release is None or uploaded > last_release:
last_release = uploaded
if earliest_upload is None or uploaded < earliest_upload:
earliest_upload = uploaded
return {
"publisher": info.get("author") or info.get("maintainer"),
"age_days": _iso_age_days(earliest_upload),
"last_release": last_release,
"downloads": None, # PyPI JSON API doesn't include download counts
}
def _iso_age_days(iso_timestamp: str | None) -> int | None:
if not iso_timestamp:
return None
import datetime as _dt
try:
parsed = _dt.datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00"))
now = _dt.datetime.now(_dt.timezone.utc)
return max((now - parsed).days, 0)
except ValueError:
return None
def registry_fetcher(ref: review.PackageRef) -> dict | None:
"""The Fetcher passed to review.lookup_registry_info(). Never raises --
both fetch_npm_info/fetch_pypi_info already fail soft, and
lookup_registry_info() wraps this in a try/except regardless."""
if ref.ecosystem == "npm":
return fetch_npm_info(ref)
if ref.ecosystem == "pypi":
return fetch_pypi_info(ref)
return None
# --------------------------------------------------------------------------- #
# GUI (PySide6). Everything above this line has no Qt dependency and is
# exercised by tests/test_catalog_review.py; everything below is a thin
# shell that calls into it.
# --------------------------------------------------------------------------- #
from PySide6.QtCore import Qt, QThread, Signal # noqa: E402
from PySide6.QtWidgets import ( # noqa: E402
QApplication,
QCheckBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMainWindow,
QMessageBox,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
def plain_label(text: object) -> QLabel:
"""A QLabel guaranteed to render `text` as plain text, never HTML.
Qt's QLabel auto-interprets HTML by default (Qt.AutoText), which means
an attacker-controlled description/notes/URL/package-name string
containing `<b>` or `<img onerror=...>` would render as markup instead
of visible text -- exactly the kind of thing that could hide a homoglyph
swap or make a risk warning easy to miss. Every catalog-derived string
shown by this Console MUST go through this helper (or otherwise set
Qt.PlainText explicitly) rather than a bare QLabel(...).
"""
label = QLabel(html.escape(str(text)))
label.setTextFormat(Qt.PlainText)
label.setWordWrap(True)
return label
_SEVERITY_PREFIX = {"blocking": "✖ BLOCKING", "warning": "⚠ WARNING", "info": " INFO"}
class RegistryLookupWorker(QThread):
"""Off-UI-thread registry lookups, mirroring bcc.py's ConnTester/
SpawnTester pattern. Never blocks the review UI on a slow/dead network."""
done = Signal(object) # list[review.RegistryInfo]
def __init__(self, refs: list[review.PackageRef], all_entry_ids: list[str]):
super().__init__()
self._refs = refs
self._all_entry_ids = all_entry_ids
def run(self):
results = [
review.lookup_registry_info(ref, registry_fetcher, self._all_entry_ids)
for ref in self._refs
]
self.done.emit(results)
class EntryCard(QWidget):
"""One changed catalog entry: the diff, risk findings, and the
acknowledge checkbox that gates Sign. `command`/`args` are rendered
visually dominant (bold-weight, larger, first) since they're the fields
that execute.
"""
acknowledged_changed = Signal(str, bool)
def __init__(self, change: review.EntryChange, all_entry_ids: list[str]):
super().__init__()
self.change = change
self._all_entry_ids = all_entry_ids
self._worker: RegistryLookupWorker | None = None
outline = QVBoxLayout(self)
box = QGroupBox(f"[{change.status.upper()}] {change.entry_id}")
outline.addWidget(box)
layout = QVBoxLayout(box)
entry = change.new or change.old or {}
config = entry.get("config") or {}
cmd_label = plain_label(f"command: {config.get('command', '(none)')}")
cmd_label.setStyleSheet("font-weight: bold; font-size: 13pt;")
layout.addWidget(cmd_label)
args_label = plain_label(f"args: {config.get('args', [])}")
args_label.setStyleSheet("font-weight: bold;")
layout.addWidget(args_label)
for fc in change.field_changes:
if fc.field in ("config.command", "config.args"):
continue # already shown dominant, above
layout.addWidget(plain_label(f"{fc.field}: {fc.old!r} -> {fc.new!r}"))
findings = review.entry_risk_findings(change)
for finding in findings:
prefix = _SEVERITY_PREFIX.get(finding.severity, finding.severity.upper())
flabel = plain_label(f"{prefix}: {finding.message}")
if finding.severity == "blocking":
flabel.setStyleSheet("color: #c62828; font-weight: bold;")
elif finding.severity == "warning":
flabel.setStyleSheet("color: #ef6c00;")
else:
flabel.setStyleSheet("color: #1565c0;")
layout.addWidget(flabel)
self.registry_label = plain_label("Registry lookup: loading...")
layout.addWidget(self.registry_label)
recheck_btn = QPushButton("Re-check")
recheck_btn.clicked.connect(self._run_registry_lookup)
layout.addWidget(recheck_btn)
self.blocking = any(f.severity == "blocking" for f in findings)
self.checkbox = QCheckBox(
"I have reviewed this entry, including command/args and the risk"
" annotations above, and approve it."
)
if self.blocking:
self.checkbox.setEnabled(False)
self.checkbox.setToolTip(
"This entry has a BLOCKING finding and cannot be acknowledged "
"until the underlying change is fixed (edit the PR, don't sign around it)."
)
self.checkbox.toggled.connect(
lambda checked: self.acknowledged_changed.emit(change.entry_id, checked)
)
layout.addWidget(self.checkbox)
# Registry lookup is the one check a reviewer can't do by eye -- it's
# what catches a typosquatted/hijacked package (it already caught
# firecrawl-mcp in the seed data). It must run automatically as soon
# as the card exists, not wait on a click a tired maintainer might
# skip at 11pm. Off the GUI thread (RegistryLookupWorker is a
# QThread) and fails soft: a dead/slow registry can never gate
# review or signing, it just leaves this entry's lookup showing
# "unavailable". The "Re-check" button above stays for retrying a
# failed/unavailable lookup by hand.
self._run_registry_lookup()
def _run_registry_lookup(self):
entry = self.change.new or {}
refs = review.extract_package_refs(entry)
if not refs:
self.registry_label.setText("Registry lookup: no npm/PyPI package in this entry.")
return
self.registry_label.setText("Registry lookup: loading...")
self._worker = RegistryLookupWorker(refs, self._all_entry_ids)
self._worker.done.connect(self._on_registry_result)
self._worker.start()
def _on_registry_result(self, results: list[review.RegistryInfo]):
lines = []
for info in results:
if not info.available:
lines.append(f"{info.ref.name}: unavailable (network/registry unreachable)")
continue
neighbor_note = (
f" | NEAR-NEIGHBOUR of: {', '.join(info.near_neighbor_ids)}"
if info.near_neighbor_ids
else ""
)
lines.append(
f"{info.ref.name}: publisher={info.publisher!r} age_days={info.age_days} "
f"last_release={info.last_release} downloads={info.downloads}{neighbor_note}"
)
text = "Registry lookup:\n" + "\n".join(lines)
self.registry_label.setText(html.escape(text))
self.registry_label.setTextFormat(Qt.PlainText)
class PassphraseDialog(QDialog):
def __init__(self, prompt: str, parent=None):
super().__init__(parent)
self.setWindowTitle("Signing key passphrase")
layout = QFormLayout(self)
self.edit = QLineEdit()
self.edit.setEchoMode(QLineEdit.EchoMode.Password)
layout.addRow(prompt, self.edit)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addRow(buttons)
def passphrase(self) -> str:
return self.edit.text()
class ReviewWindow(QMainWindow):
def __init__(self, repo_dir: Path):
super().__init__()
self.repo_dir = repo_dir
self.session: review.ReviewSession | None = None
self.cards: dict[str, EntryCard] = {}
self.setWindowTitle("BCC Catalog Console -- maintainer-only, never shipped")
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
top = QHBoxLayout()
self.source_list = QListWidget()
self.source_list.addItem(QListWidgetItem("main (current tip)"))
top.addWidget(self.source_list, 1)
side = QVBoxLayout()
load_btn = QPushButton("Load selected source")
load_btn.clicked.connect(self._on_load)
side.addWidget(load_btn)
refresh_prs_btn = QPushButton("Refresh open PR list")
refresh_prs_btn.clicked.connect(self._refresh_pr_list)
side.addWidget(refresh_prs_btn)
side.addStretch(1)
top.addLayout(side)
root.addLayout(top)
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.card_container = QWidget()
self.card_layout = QVBoxLayout(self.card_container)
self.scroll.setWidget(self.card_container)
root.addWidget(self.scroll, 1)
self.status_label = plain_label("Load a source to begin review.")
root.addWidget(self.status_label)
self.sign_btn = QPushButton("Sign")
self.sign_btn.setEnabled(False)
self.sign_btn.clicked.connect(self._on_sign)
root.addWidget(self.sign_btn)
self._token = token_from_git_remote(self.repo_dir)
self._prs: list[CatalogPR] = []
self._refresh_pr_list()
def _refresh_pr_list(self):
self._prs = list_open_catalog_prs(self._token)
while self.source_list.count() > 1:
self.source_list.takeItem(1)
for pr in self._prs:
self.source_list.addItem(QListWidgetItem(f"PR #{pr.number}: {pr.title}"))
def _on_load(self):
row = self.source_list.currentRow()
try:
if row <= 0:
commit = fetch_ref(self.repo_dir, "main")
old_commit = None # main vs itself has no "old" -- nothing to diff without a base
else:
pr = self._prs[row - 1]
commit = fetch_ref(self.repo_dir, pr.head_ref)
old_commit = fetch_ref(self.repo_dir, "main")
new_raw, new_blob_sha = read_catalog_at_commit(self.repo_dir, commit)
new_catalog = core.load_catalog(new_raw)
if old_commit:
old_raw, _old_sha = read_catalog_at_commit(self.repo_dir, old_commit)
old_catalog = core.load_catalog(old_raw)
else:
old_catalog = new_catalog
except (GitError, ValueError) as e:
QMessageBox.critical(self, "Load failed", html.escape(str(e)))
return
self._new_raw = new_raw
self.session = review.start_review(new_blob_sha, old_catalog, new_catalog)
self._render_cards()
def _render_cards(self):
while self.card_layout.count():
item = self.card_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
self.cards.clear()
assert self.session is not None
all_ids = sorted(
{e.get("id") for e in (self.session.new_catalog.get("servers") or []) if e.get("id")}
)
for change in self.session.changes:
card = EntryCard(change, all_ids)
card.acknowledged_changed.connect(self._on_acknowledge_changed)
self.cards[change.entry_id] = card
self.card_layout.addWidget(card)
self.card_layout.addStretch(1)
self._update_status()
def _on_acknowledge_changed(self, entry_id: str, checked: bool):
assert self.session is not None
if checked:
review.acknowledge_entry(self.session, entry_id)
else:
review.unacknowledge_entry(self.session, entry_id)
self._update_status()
def _update_status(self):
assert self.session is not None
all_ack = review.all_entries_acknowledged(self.session)
self.sign_btn.setEnabled(all_ack)
pending = len(self.session.changes) - len(self.session.acknowledged)
self.status_label.setText(
f"{len(self.session.changes)} changed entries, {pending} not yet acknowledged."
)
def _on_sign(self):
assert self.session is not None
try:
current_sha = blob_sha_at(self.repo_dir, fetch_ref(self.repo_dir, "main"), CATALOG_PATH)
except GitError as e:
QMessageBox.critical(self, "Sign failed", html.escape(str(e)))
return
decision = review.can_sign(self.session, current_sha)
if not decision.ok:
QMessageBox.warning(self, "Cannot sign", html.escape(decision.reason or ""))
if decision.reason and "changed" in decision.reason.lower():
self._on_load() # force a re-review against the new bytes
return
dialog = PassphraseDialog("Enter signing key passphrase:", self)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
try:
seed = unlock_signing_key(dialog.passphrase())
except (FileNotFoundError, ValueError) as e:
QMessageBox.critical(self, "Sign failed", html.escape(str(e)))
return
signature = review.sign_catalog_bytes(self._new_raw, seed)
try:
new_commit = commit_and_push_signed_catalog(self.repo_dir, self._new_raw, signature)
except GitError as e:
QMessageBox.critical(self, "Commit/push failed", html.escape(str(e)))
return
QMessageBox.information(self, "Signed", f"Signed and pushed as commit {new_commit[:12]}.")
self.sign_btn.setEnabled(False)
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def cmd_keygen(_args: argparse.Namespace) -> int:
seed, pubkey = review.generate_keypair()
passphrase = getpass.getpass("Choose a passphrase to encrypt the new signing key: ")
confirm = getpass.getpass("Confirm passphrase: ")
if passphrase != confirm:
print("error: passphrases did not match", file=sys.stderr)
return 1
if not passphrase:
print("error: a non-empty passphrase is required", file=sys.stderr)
return 1
blob = review.encrypt_private_key(seed, passphrase)
where = store_encrypted_key(blob)
pubkey_b64 = __import__("base64").b64encode(pubkey).decode("ascii")
print(f"Private key encrypted and stored in: {where}")
print()
print("Public key (base64, paste into bcc_core.CATALOG_PUBKEYS):")
print(f" {pubkey_b64}")
print()
print(
"Also add it as the Gitea repo secret RELEASE_SIGNING_KEY (base64 of the "
"32-byte private seed) used by release.yml -- get that value with:"
)
print(" python catalog_console.py show-seed-b64 # careful: prints the raw key")
return 0
def cmd_show_seed_b64(_args: argparse.Namespace) -> int:
passphrase = getpass.getpass("Signing key passphrase: ")
try:
seed = unlock_signing_key(passphrase)
except (FileNotFoundError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 1
import base64
print(base64.b64encode(seed).decode("ascii"))
return 0
def cmd_gui(args: argparse.Namespace) -> int:
repo_dir = Path(args.repo).resolve()
if not (repo_dir / CATALOG_PATH).exists():
print(
f"error: {repo_dir} doesn't look like a BCC checkout (no {CATALOG_PATH})",
file=sys.stderr,
)
return 1
app = QApplication(sys.argv)
app.setApplicationName("BCC Catalog Console")
win = ReviewWindow(repo_dir)
win.resize(900, 700)
win.show()
return app.exec()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command")
p_gui = sub.add_parser("gui", help="launch the review/sign GUI (default)")
p_gui.add_argument("--repo", default=".", help="path to a BCC git checkout (default: cwd)")
p_gui.set_defaults(func=cmd_gui)
p_keygen = sub.add_parser("keygen", help="generate a new Ed25519 signing keypair")
p_keygen.set_defaults(func=cmd_keygen)
p_seed = sub.add_parser(
"show-seed-b64", help="print the base64 private seed (for the RELEASE_SIGNING_KEY secret)"
)
p_seed.set_defaults(func=cmd_show_seed_b64)
return parser
_SUBCOMMANDS = ("gui", "keygen", "show-seed-b64", "-h", "--help")
def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:] if argv is None else list(argv)
# `python catalog_console.py` with no subcommand (or with GUI-only flags
# like --repo) launches the GUI -- "gui" is the default action.
if not argv or argv[0] not in _SUBCOMMANDS:
argv = ["gui", *argv]
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
+727
View File
@@ -0,0 +1,727 @@
"""
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
# --------------------------------------------------------------------------- #
# 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()."""
pinned_blob_sha: str
old_catalog: dict
new_catalog: dict
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) -> ReviewSession:
return ReviewSession(
pinned_blob_sha=pinned_blob_sha, old_catalog=old_catalog, new_catalog=new_catalog
)
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.
Two independent gates, both required:
1. TOCTOU: `current_blob_sha` (fetched fresh, immediately before signing)
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. Every changed entry in the diff must be individually acknowledged.
"""
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.",
)
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 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))
+454
View File
@@ -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."
}
]
}
+2
View File
@@ -0,0 +1,2 @@
ы<8¶ђt2ішл„»‰/НЕ0Тjcw&`
тrH«MўК›єrBL,0AS€!Х2–иже.SТ°ч–'Agm
+1
View File
@@ -7,6 +7,7 @@ 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
View File
@@ -8,3 +8,4 @@ pillow>=10.0 # generates icons/app.ico during CI (Windows build)
# Test / lint # Test / lint
pytest>=8.0 pytest>=8.0
ruff>=0.6 ruff>=0.6
cryptography>=42.0 # release checksum signing (scripts/sign_checksums.py)
+235
View File
@@ -0,0 +1,235 @@
#!/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|"
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")
# --------------------------------------------------------------------------- #
# 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())
+66
View File
@@ -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
+555
View File
@@ -0,0 +1,555 @@
"""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_function_exists():
"""Deliberate: there must be no shortcut to acknowledge every entry at
once. See the comment in catalog_review.py above SignDecision."""
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
# --------------------------------------------------------------------------- #
# 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()
# --------------------------------------------------------------------------- #
# 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
+234
View File
@@ -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
+738
View File
@@ -10,6 +10,7 @@ import urllib.request
from pathlib import Path from pathlib import Path
import pytest import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
import bcc_core as c import bcc_core as c
@@ -1668,3 +1669,740 @@ def test_app_icon_assets_present():
assert (rounded / f"icon-{size}.png").is_file(), f"missing icon-{size}.png" assert (rounded / f"icon-{size}.png").is_file(), f"missing icon-{size}.png"
assert (root / "icons" / "app.ico").is_file() assert (root / "icons" / "app.ico").is_file()
assert (root / "icons" / "app.icns").is_file() assert (root / "icons" / "app.icns").is_file()
# --------------------------------------------------------------------------- #
# MCP server catalog (issue #10 / #61)
# --------------------------------------------------------------------------- #
def _minimal_catalog(version: int = 1) -> dict:
return {
"schema": 1,
"version": version,
"updated": "2026-07-12",
"servers": [
{
"id": "widget",
"display": "Widget",
"description": "A test widget server.",
"category": "dev",
"homepage": "https://example.com/widget",
"stars": 10,
"official": True,
"setup": "basic",
"config": {
"command": "npx",
"args": ["-y", "widget-mcp"],
},
"placeholders": {},
"env_required": {},
"docs_url": "https://example.com/widget/docs",
"notes": "",
}
],
}
def _catalog_with(server_overrides: dict) -> dict:
data = _minimal_catalog()
data["servers"][0].update(server_overrides)
return data
def _sign(raw: bytes, priv: Ed25519PrivateKey) -> bytes:
# Independent of bcc_core's domain-separation constant on purpose: this
# is the literal wire format the design calls for, hardcoded here so a
# change to the constant would be caught as a real behaviour change.
return priv.sign(b"bcc-catalog-v1|" + raw)
def _signed(data: dict, priv: Ed25519PrivateKey) -> tuple[bytes, bytes]:
raw = json.dumps(data).encode("utf-8")
return raw, _sign(raw, priv)
# --- load_catalog / validate_catalog: valid round trip ------------------- #
def test_load_catalog_valid_round_trip():
data = _minimal_catalog()
raw = json.dumps(data).encode("utf-8")
loaded = c.load_catalog(raw)
assert loaded == data
assert c.validate_catalog(loaded) == []
assert c.catalog_version(loaded) == 1
def test_load_catalog_accepts_str_too():
data = _minimal_catalog()
text = json.dumps(data)
assert c.load_catalog(text) == data
def test_load_catalog_malformed_raises_json_decode_error():
# load_catalog is strict json.loads ONLY -- it must never silently
# "repair" malformed bytes into something that parses.
with pytest.raises(json.JSONDecodeError):
c.load_catalog(b"{not valid json")
def test_shipped_catalog_json_passes_validation():
"""Regression test: the real data/catalog.json bundled with the app."""
root = Path(c.__file__).resolve().parent
raw = (root / "data" / "catalog.json").read_bytes()
data = c.load_catalog(raw)
problems = c.validate_catalog(data)
assert problems == [], problems
assert c.catalog_version(data) >= 1
# --- verify_catalog_signature --------------------------------------------- #
def test_verify_catalog_signature_valid():
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
raw = json.dumps(_minimal_catalog()).encode("utf-8")
sig = _sign(raw, priv)
assert c.verify_catalog_signature(raw, sig, [pub]) is True
def test_verify_catalog_signature_tampered_byte_fails():
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
raw = json.dumps(_minimal_catalog()).encode("utf-8")
sig = _sign(raw, priv)
tampered = bytearray(raw)
tampered[0] ^= 0xFF # flip exactly one byte
assert c.verify_catalog_signature(bytes(tampered), sig, [pub]) is False
def test_verify_catalog_signature_wrong_key_fails():
priv = Ed25519PrivateKey.generate()
other_pub = Ed25519PrivateKey.generate().public_key().public_bytes_raw()
raw = json.dumps(_minimal_catalog()).encode("utf-8")
sig = _sign(raw, priv)
assert c.verify_catalog_signature(raw, sig, [other_pub]) is False
def test_verify_catalog_signature_matches_any_key_in_list():
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
other_pub = Ed25519PrivateKey.generate().public_key().public_bytes_raw()
raw = json.dumps(_minimal_catalog()).encode("utf-8")
sig = _sign(raw, priv)
# signing key is second in the list -- rotation support
assert c.verify_catalog_signature(raw, sig, [other_pub, pub]) is True
def test_verify_catalog_signature_garbage_sig_fails():
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
raw = json.dumps(_minimal_catalog()).encode("utf-8")
assert c.verify_catalog_signature(raw, b"not-a-real-signature", [pub]) is False
assert c.verify_catalog_signature(raw, b"", [pub]) is False
def test_verify_catalog_signature_missing_signature_returns_false():
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
raw = json.dumps(_minimal_catalog()).encode("utf-8")
assert c.verify_catalog_signature(raw, None, [pub]) is False
def test_verify_catalog_signature_never_raises_on_garbage_inputs():
assert c.verify_catalog_signature(b"", b"", []) is False
assert c.verify_catalog_signature(b"x", b"y", [b"too-short"]) is False
assert c.verify_catalog_signature("not-bytes", b"y", [b"\x00" * 32]) is False
assert c.verify_catalog_signature(b"x", b"y", None) is False
# --- validate_catalog: per-rule rejections --------------------------------- #
def test_validate_catalog_rejects_non_dict_root():
assert c.validate_catalog(["not", "a", "dict"]) != []
def test_validate_catalog_rejects_bad_schema_and_version():
data = _minimal_catalog()
data["schema"] = 0
data["version"] = -1
problems = c.validate_catalog(data)
assert any("schema" in p for p in problems)
assert any("version" in p for p in problems)
def test_validate_catalog_basic_requires_config():
data = _catalog_with({"config": None})
problems = c.validate_catalog(data)
assert any("config" in p for p in problems)
def test_validate_catalog_link_only_forbids_config():
data = _minimal_catalog()
data["servers"][0] = {
"id": "hosted",
"display": "Hosted",
"description": "A hosted connector.",
"category": "dev",
"homepage": "https://example.com/hosted",
"official": True,
"setup": "link-only",
"env_required": {},
"docs_url": "https://example.com/hosted/docs",
"notes": "",
"config": {"command": "npx", "args": ["-y", "should-not-be-here"]},
}
problems = c.validate_catalog(data)
assert any("must not have a 'config'" in p for p in problems)
def test_validate_catalog_rejects_disallowed_command():
data = _catalog_with({"config": {"command": "bash", "args": ["-c", "echo hi"]}})
problems = c.validate_catalog(data)
assert any("allowlist" in p for p in problems)
def test_validate_catalog_rejects_node_eval_flag():
data = _catalog_with({"config": {"command": "node", "args": ["-e", "require('fs')"]}})
problems = c.validate_catalog(data)
assert any("-e/--eval/-c" in p for p in problems)
def test_validate_catalog_rejects_python_c_flag():
data = _catalog_with({"config": {"command": "python3", "args": ["-c", "import os"]}})
problems = c.validate_catalog(data)
assert any("-e/--eval/-c" in p for p in problems)
def test_validate_catalog_rejects_docker_privileged():
data = _catalog_with(
{"config": {"command": "docker", "args": ["run", "--privileged", "some/image"]}}
)
problems = c.validate_catalog(data)
assert any("--privileged" in p for p in problems)
def test_validate_catalog_rejects_docker_root_volume_mount():
data = _catalog_with(
{"config": {"command": "docker", "args": ["run", "-v", "/:/host", "some/image"]}}
)
problems = c.validate_catalog(data)
assert any("mounts" in p for p in problems)
def test_validate_catalog_rejects_docker_home_volume_mount():
data = _catalog_with(
{"config": {"command": "docker", "args": ["run", "--volume=$HOME:/host", "some/image"]}}
)
problems = c.validate_catalog(data)
assert any("mounts" in p for p in problems)
def test_validate_catalog_rejects_nonempty_env_required():
data = _catalog_with({"env_required": {"API_TOKEN": "sk-shouldnotbehere"}})
problems = c.validate_catalog(data)
assert any("env_required" in p for p in problems)
def test_validate_catalog_rejects_secret_looking_arg():
data = _catalog_with(
{"config": {"command": "npx", "args": ["-y", "widget-mcp", "--api-key=sk-abcdef123"]}}
)
problems = c.validate_catalog(data)
assert any("secret-looking" in p for p in problems)
def test_validate_catalog_rejects_token_prefix_positional_arg():
data = _catalog_with(
{"config": {"command": "npx", "args": ["-y", "widget-mcp", "ghp_abcdef123456"]}}
)
problems = c.validate_catalog(data)
assert any("secret-looking" in p for p in problems)
def test_validate_catalog_rejects_http_url():
data = _catalog_with({"homepage": "http://example.com/widget"})
problems = c.validate_catalog(data)
assert any("homepage" in p for p in problems)
def test_validate_catalog_rejects_file_url():
data = _catalog_with({"docs_url": "file:///etc/passwd"})
problems = c.validate_catalog(data)
assert any("docs_url" in p for p in problems)
def test_validate_catalog_rejects_non_ascii_id():
data = _catalog_with({"id": "wídget"})
problems = c.validate_catalog(data)
assert any("ASCII" in p for p in problems)
def test_validate_catalog_rejects_non_ascii_command():
data = _catalog_with({"config": {"command": "npxé", "args": ["-y", "widget-mcp"]}})
problems = c.validate_catalog(data)
assert any("ASCII" in p for p in problems)
def test_validate_catalog_rejects_non_ascii_arg():
data = _catalog_with({"config": {"command": "npx", "args": ["-y", "wídget-mcp"]}})
problems = c.validate_catalog(data)
assert any("non-ASCII" in p for p in problems)
def test_validate_catalog_rejects_duplicate_ids():
data = _minimal_catalog()
data["servers"].append(dict(data["servers"][0]))
problems = c.validate_catalog(data)
assert any("duplicate id" in p for p in problems)
# --- resolve_catalog -------------------------------------------------------- #
def test_resolve_catalog_nothing_available_returns_empty_dict():
assert c.resolve_catalog(None, None, None) == {}
def test_resolve_catalog_prefers_highest_verified_version(monkeypatch):
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub])
bundled = _signed(_minimal_catalog(version=1), priv)
cached = _signed(_minimal_catalog(version=2), priv)
remote = _signed(_minimal_catalog(version=3), priv)
result = c.resolve_catalog(bundled, cached, remote)
assert c.catalog_version(result) == 3
def test_resolve_catalog_rejects_unsigned_bundled_catalog(monkeypatch):
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub])
# Bundled claims a very high version but is NOT signed by a trusted key
# -- it must get no implicit trust just for being the local copy.
malicious_raw = json.dumps(_minimal_catalog(version=100)).encode("utf-8")
bundled = (malicious_raw, b"totally-not-a-signature")
remote = _signed(_minimal_catalog(version=3), priv)
result = c.resolve_catalog(bundled, None, remote)
assert c.catalog_version(result) == 3
def test_resolve_catalog_rejects_rolled_back_version(monkeypatch):
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub])
cached = _signed(_minimal_catalog(version=5), priv)
rolled_back_remote = _signed(_minimal_catalog(version=2), priv)
result = c.resolve_catalog(None, cached, rolled_back_remote)
assert c.catalog_version(result) == 5
def test_resolve_catalog_rejects_absurd_version_jump(monkeypatch):
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub])
cached = _signed(_minimal_catalog(version=5), priv)
freeze_attempt = _signed(_minimal_catalog(version=999999), priv)
result = c.resolve_catalog(None, cached, freeze_attempt)
assert c.catalog_version(result) == 5
def test_resolve_catalog_malformed_candidate_does_not_raise(monkeypatch):
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub])
malformed_raw = b"{not valid json"
malformed_sig = _sign(malformed_raw, priv)
good = _signed(_minimal_catalog(version=1), priv)
result = c.resolve_catalog((malformed_raw, malformed_sig), None, good)
assert c.catalog_version(result) == 1
def test_resolve_catalog_invalid_but_signed_candidate_is_skipped(monkeypatch):
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub])
invalid = _signed(_catalog_with({"config": {"command": "bash", "args": []}}), priv)
good = _signed(_minimal_catalog(version=1), priv)
result = c.resolve_catalog(invalid, None, good)
assert c.catalog_version(result) == 1
# --- catalog_entry_to_paste_json / config_has_unfilled_placeholders ------- #
def test_catalog_entry_to_paste_json_basic_shape():
entry = _minimal_catalog()["servers"][0]
result = c.catalog_entry_to_paste_json(entry)
assert result == {"widget": {"command": "npx", "args": ["-y", "widget-mcp"]}}
def test_catalog_entry_to_paste_json_includes_env_when_present():
entry = _minimal_catalog()["servers"][0]
entry["config"]["env"] = {"GRAFANA_URL": "<GRAFANA_URL>"}
result = c.catalog_entry_to_paste_json(entry)
assert result["widget"]["env"] == {"GRAFANA_URL": "<GRAFANA_URL>"}
def test_catalog_entry_to_paste_json_seeds_env_required_keys():
# Regression: env_required is where the seed data actually keeps its
# secret VAR NAMES (postgres/github/notion/etc. all declare their secret
# here with config.env left empty) -- catalog_entry_to_paste_json must
# surface those names as blank env rows, not silently drop them.
entry = _minimal_catalog()["servers"][0]
entry["env_required"] = {"DATABASE_URI": ""}
result = c.catalog_entry_to_paste_json(entry)
assert result["widget"]["env"] == {"DATABASE_URI": ""}
def test_catalog_entry_to_paste_json_config_env_wins_over_env_required_default():
entry = _minimal_catalog()["servers"][0]
entry["config"]["env"] = {"GRAFANA_URL": "<GRAFANA_URL>"}
entry["env_required"] = {"GRAFANA_URL": "", "GRAFANA_SERVICE_ACCOUNT_TOKEN": ""}
result = c.catalog_entry_to_paste_json(entry)
assert result["widget"]["env"] == {
"GRAFANA_URL": "<GRAFANA_URL>",
"GRAFANA_SERVICE_ACCOUNT_TOKEN": "",
}
def test_catalog_entry_to_paste_json_real_postgres_entry_seeds_database_uri():
"""End-to-end regression against the actual shipped postgres entry,
which needs DATABASE_URI via env_required and has no config.env at
all -- this is exactly the shape that was silently dropping the env
field before catalog_entry_to_paste_json accounted for env_required."""
root = Path(__file__).resolve().parent.parent
raw = (root / "data" / "catalog.json").read_bytes()
data = c.load_catalog(raw)
entry = next(s for s in data["servers"] if s["id"] == "postgres")
result = c.catalog_entry_to_paste_json(entry)
assert result["postgres"]["env"] == {"DATABASE_URI": ""}
# And the focus-target helper now has something to point the user at.
assert c.first_unfilled_focus_target(result["postgres"]) == ("env", "DATABASE_URI")
def test_config_has_unfilled_placeholders_true_for_token():
cfg = {"command": "npx", "args": ["-y", "server", "<ALLOWED_DIR>"]}
assert c.config_has_unfilled_placeholders(cfg) is True
def test_config_has_unfilled_placeholders_false_after_fill():
cfg = {"command": "npx", "args": ["-y", "server", "/Users/me/project"]}
assert c.config_has_unfilled_placeholders(cfg) is False
def test_config_has_unfilled_placeholders_checks_env_too():
cfg = {"command": "uvx", "args": ["mcp-grafana"], "env": {"GRAFANA_URL": "<GRAFANA_URL>"}}
assert c.config_has_unfilled_placeholders(cfg) is True
# --------------------------------------------------------------------------- #
# Browse-catalog dialog helpers (issue #10 phase 2)
# --------------------------------------------------------------------------- #
# --- catalog_category_group / CATALOG_CATEGORY_GROUPS --------------------- #
@pytest.mark.parametrize(
"category,expected_group",
[
("files", "Files & Dev"),
("dev", "Files & Dev"),
("code-hosting", "Files & Dev"),
("browser", "Files & Dev"),
("database", "Data"),
("data", "Data"),
("search", "Search & AI"),
("ai", "Search & AI"),
("cloud", "Cloud & Infra"),
("infra", "Cloud & Infra"),
("observability", "Cloud & Infra"),
("productivity", "Work"),
("communication", "Work"),
("crm", "Work"),
("finance", "Work"),
("design", "Work"),
("media", "Home & Personal"),
("smart-home", "Home & Personal"),
("personal", "Home & Personal"),
],
)
def test_catalog_category_group_maps_every_taxonomy_value(category, expected_group):
assert c.catalog_category_group(category) == expected_group
def test_catalog_category_group_unknown_falls_back_to_other():
assert c.catalog_category_group("some-future-category-nobody-has-seen-yet") == "Other"
assert c.catalog_category_group("") == "Other"
assert c.catalog_category_group(None) == "Other"
def test_catalog_category_group_is_case_insensitive():
assert c.catalog_category_group("Files") == "Files & Dev"
assert c.catalog_category_group("DATABASE") == "Data"
def test_shipped_catalog_categories_all_have_a_known_group():
"""Regression: every category actually used in data/catalog.json must
collapse to one of the 7 chips, never silently drop an entry."""
root = Path(__file__).resolve().parent.parent
raw = (root / "data" / "catalog.json").read_bytes()
data = c.load_catalog(raw)
for entry in data["servers"]:
group = c.catalog_category_group(entry["category"])
assert group in c.CATALOG_CATEGORY_CHIPS
# --- catalog_entry_matches_query / filter_catalog_entries ------------------ #
def _catalog_entries():
return [
{
"id": "filesystem",
"display": "Filesystem",
"description": "Read/write access to local directories you choose.",
"category": "files",
},
{
"id": "postgres",
"display": "Postgres MCP Pro",
"description": "Query and inspect a PostgreSQL database.",
"category": "database",
},
{
"id": "slack",
"display": "Slack",
"description": "Search messages and send messages from your assistant.",
"category": "communication",
},
]
def test_catalog_entry_matches_query_empty_matches_everything():
entries = _catalog_entries()
assert c.filter_catalog_entries(entries, "") == entries
assert c.filter_catalog_entries(entries, " ") == entries
def test_catalog_entry_matches_query_matches_id():
result = c.filter_catalog_entries(_catalog_entries(), "postgres")
assert [e["id"] for e in result] == ["postgres"]
def test_catalog_entry_matches_query_matches_display_case_insensitive():
result = c.filter_catalog_entries(_catalog_entries(), "SLACK")
assert [e["id"] for e in result] == ["slack"]
def test_catalog_entry_matches_query_matches_description():
result = c.filter_catalog_entries(_catalog_entries(), "PostgreSQL database")
assert [e["id"] for e in result] == ["postgres"]
def test_catalog_entry_matches_query_matches_category():
result = c.filter_catalog_entries(_catalog_entries(), "database")
assert [e["id"] for e in result] == ["postgres"]
def test_catalog_entry_matches_query_no_match_returns_empty():
assert c.filter_catalog_entries(_catalog_entries(), "kubernetes") == []
def test_catalog_entries_in_group_all_returns_everything():
entries = _catalog_entries()
assert c.catalog_entries_in_group(entries, "All") == entries
assert c.catalog_entries_in_group(entries, "") == entries
assert c.catalog_entries_in_group(entries, None) == entries
def test_catalog_entries_in_group_filters_by_collapsed_category():
result = c.catalog_entries_in_group(_catalog_entries(), "Data")
assert [e["id"] for e in result] == ["postgres"]
result = c.catalog_entries_in_group(_catalog_entries(), "Work")
assert [e["id"] for e in result] == ["slack"]
# --- format_freshness_hint -------------------------------------------------- #
def test_format_freshness_hint_none_returns_empty_string():
assert c.format_freshness_hint(None) == ""
assert c.format_freshness_hint("") == ""
def test_format_freshness_hint_unparseable_returns_empty_string():
assert c.format_freshness_hint("not-a-date") == ""
def test_format_freshness_hint_this_month():
assert (
c.format_freshness_hint("2026-07-01", today=c.date(2026, 7, 12))
== "Last updated this month"
)
def test_format_freshness_hint_one_month_singular():
assert (
c.format_freshness_hint("2026-06-01", today=c.date(2026, 7, 12))
== "Last updated 1 month ago"
)
def test_format_freshness_hint_months_ago():
# Exactly 14 full months elapsed, no day-of-month remainder to round off.
assert (
c.format_freshness_hint("2025-01-15", today=c.date(2026, 3, 15))
== "Last updated 14 months ago"
)
def test_format_freshness_hint_rounds_down_partial_month():
# 2025-05-16 -> 2026-07-12 is 13 full months, not 14: the 14th month
# would only complete on 2026-07-16.
assert (
c.format_freshness_hint("2025-05-16", today=c.date(2026, 7, 12))
== "Last updated 13 months ago"
)
def test_format_freshness_hint_years_ago():
assert (
c.format_freshness_hint("2024-01-01", today=c.date(2026, 7, 12))
== "Last updated 2 years ago"
)
def test_format_freshness_hint_23_months_stays_in_months_not_years():
# The switch to "N years ago" happens at 24 full months, not 12 -- the
# whole point of this hint is the granular "14 months ago" phrasing the
# design comment on #10 asked for, so 13-23 months must stay in months.
assert (
c.format_freshness_hint("2024-08-12", today=c.date(2026, 7, 12))
== "Last updated 23 months ago"
)
def test_format_freshness_hint_future_date_returns_empty_string():
# A last_release "in the future" relative to `today` is nonsensical --
# show nothing rather than a misleading negative offset.
assert c.format_freshness_hint("2027-01-01", today=c.date(2026, 7, 12)) == ""
# --- first_unfilled_focus_target -------------------------------------------- #
def test_first_unfilled_focus_target_prefers_placeholder_arg():
data = {
"command": "npx",
"args": ["-y", "server", "<ALLOWED_DIR>"],
"env": {"API_KEY": ""},
}
assert c.first_unfilled_focus_target(data) == ("args", 2)
def test_first_unfilled_focus_target_falls_back_to_first_blank_env():
data = {"command": "uvx", "args": ["mcp-grafana"], "env": {"GRAFANA_URL": ""}}
assert c.first_unfilled_focus_target(data) == ("env", "GRAFANA_URL")
def test_first_unfilled_focus_target_none_when_fully_filled():
data = {"command": "npx", "args": ["-y", "server"], "env": {"API_KEY": "sk-real-value"}}
assert c.first_unfilled_focus_target(data) is None
def test_first_unfilled_focus_target_none_for_config_with_no_env_or_args():
assert c.first_unfilled_focus_target({"command": "npx", "args": []}) is None
# --- load_bundled_catalog_entries ------------------------------------------- #
def test_load_bundled_catalog_entries_valid_signature(tmp_path, monkeypatch):
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub])
raw, sig = _signed(_minimal_catalog(version=1), priv)
catalog_path = tmp_path / "catalog.json"
sig_path = tmp_path / "catalog.json.sig"
catalog_path.write_bytes(raw)
sig_path.write_bytes(sig)
entries = c.load_bundled_catalog_entries(catalog_path, sig_path)
assert len(entries) == 1
assert entries[0]["id"] == "widget"
def test_load_bundled_catalog_entries_tampered_payload_returns_empty_list(tmp_path, monkeypatch):
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub])
raw, sig = _signed(_minimal_catalog(version=1), priv)
tampered = bytearray(raw)
tampered[-2] ^= 0xFF # flip a byte inside the trailing bytes, still valid-ish JSON shape
catalog_path = tmp_path / "catalog.json"
sig_path = tmp_path / "catalog.json.sig"
catalog_path.write_bytes(bytes(tampered))
sig_path.write_bytes(sig)
assert c.load_bundled_catalog_entries(catalog_path, sig_path) == []
def test_load_bundled_catalog_entries_wrong_key_returns_empty_list(tmp_path, monkeypatch):
priv = Ed25519PrivateKey.generate()
other_priv = Ed25519PrivateKey.generate()
other_pub = other_priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [other_pub])
raw, sig = _signed(_minimal_catalog(version=1), priv) # signed by the WRONG key
catalog_path = tmp_path / "catalog.json"
sig_path = tmp_path / "catalog.json.sig"
catalog_path.write_bytes(raw)
sig_path.write_bytes(sig)
assert c.load_bundled_catalog_entries(catalog_path, sig_path) == []
def test_load_bundled_catalog_entries_missing_files_returns_empty_list(tmp_path):
assert c.load_bundled_catalog_entries(tmp_path / "nope.json", tmp_path / "nope.json.sig") == []
def test_load_bundled_catalog_entries_missing_sig_returns_empty_list(tmp_path, monkeypatch):
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub])
raw, _sig = _signed(_minimal_catalog(version=1), priv)
catalog_path = tmp_path / "catalog.json"
catalog_path.write_bytes(raw)
missing_sig_path = tmp_path / "catalog.json.sig" # never written
assert c.load_bundled_catalog_entries(catalog_path, missing_sig_path) == []
def test_load_bundled_catalog_entries_invalid_but_signed_returns_empty_list(tmp_path, monkeypatch):
"""A payload that verifies but fails validate_catalog() (disallowed
command) must still come back empty -- signing is necessary, not
sufficient."""
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub])
raw, sig = _signed(_catalog_with({"config": {"command": "bash", "args": []}}), priv)
catalog_path = tmp_path / "catalog.json"
sig_path = tmp_path / "catalog.json.sig"
catalog_path.write_bytes(raw)
sig_path.write_bytes(sig)
assert c.load_bundled_catalog_entries(catalog_path, sig_path) == []
def test_load_bundled_catalog_entries_real_shipped_catalog():
"""End-to-end regression against the actual bundled data/catalog.json +
.sig, using the real CATALOG_PUBKEYS (no monkeypatch) -- this is what
the Browse dialog actually calls on startup."""
root = Path(__file__).resolve().parent.parent
entries = c.load_bundled_catalog_entries(
root / "data" / "catalog.json", root / "data" / "catalog.json.sig"
)
assert len(entries) == 19
assert {e["id"] for e in entries} >= {"filesystem", "github", "slack", "postgres"}