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
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
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user