Files
Cowork Agent 48904c7787
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
feat: MCP server catalog core -- signed, validated, resolvable (#10, #61)
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

112 lines
2.9 KiB
RPMSpec

# -*- mode: python ; coding: utf-8 -*-
#
# PyInstaller spec for Better Claude Config.
#
# macOS → onedir build wrapped in a .app bundle (double-click native)
# Windows → onefile .exe (no console window)
# Linux → onefile binary (no console window)
#
# Build:
# pip install pyinstaller
# pyinstaller bcc.spec
#
# Output:
# macOS → dist/BetterClaudeConfig.app
# Windows → dist/BetterClaudeConfig.exe
# Linux → dist/BetterClaudeConfig
import sys
APP_NAME = "BetterClaudeConfig"
BUNDLE_ID = "com.betterclaude.config"
if sys.platform == "darwin":
icon = "icons/app.icns"
elif sys.platform == "win32":
icon = "icons/app.ico"
else:
icon = None # Linux: icon embedded via .desktop file, not the binary
a = Analysis(
["bcc.py"],
pathex=[],
binaries=[],
datas=[("icons", "icons"), ("data/catalog.json", "data")],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=["_tkinter", "tkinter"],
noarchive=False,
)
pyz = PYZ(a.pure)
if sys.platform == "darwin":
# ------------------------------------------------------------------ macOS
# onedir so PyInstaller produces a proper .app bundle structure.
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name=APP_NAME,
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
argv_emulation=True, # lets macOS open files by passing argv
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=icon,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name=APP_NAME,
)
app = BUNDLE(
coll,
name=f"{APP_NAME}.app",
icon=icon,
bundle_identifier=BUNDLE_ID,
info_plist={
"CFBundleName": "Better Claude Config",
"CFBundleDisplayName": "Better Claude Config",
"CFBundleShortVersionString": "1.3.0",
"CFBundleVersion": "1.3.0",
"NSHighResolutionCapable": True,
"NSRequiresAquaSystemAppearance": False, # supports dark mode
"LSMinimumSystemVersion": "11.0",
},
)
else:
# ----------------------------------------------- Windows / Linux (onefile)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name=APP_NAME,
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False, # UPX can strip icon resources from the PE on Windows
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=icon,
)