5 Commits

Author SHA1 Message Date
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
11 changed files with 1845 additions and 3 deletions
+2 -1
View File
@@ -62,8 +62,9 @@ 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
+67 -1
View File
@@ -107,11 +107,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 +180,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 +202,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
+1 -1
View File
@@ -31,7 +31,7 @@ a = Analysis(
["bcc.py"], ["bcc.py"],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[("icons", "icons")], datas=[("icons", "icons"), ("data/catalog.json", "data")],
hiddenimports=[], hiddenimports=[],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
+396
View File
@@ -32,6 +32,9 @@ 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 +2146,396 @@ 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] = [
b"\x00" * 32, # TODO: real key from Catalog Console (#62)
]
# 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
(future) catalog picker dialog can feed a selection straight into the
existing paste-import path instead of growing a parallel one.
"""
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 = config.get("env")
if env:
data["env"] = dict(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)
+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."
}
]
}
+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())
+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
+394
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,396 @@ 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_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