Commit Graph

1 Commits

Author SHA1 Message Date
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