9 Commits

Author SHA1 Message Date
the_og a73f2e3883 feat: author ${VAR} references, gated on whether the client expands them (#76)
CI / Lint (ruff) (pull_request) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 12s
CI / Catalog signature (pull_request) Successful in 7s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
The blocker on this issue was whether BCC or the client does the expanding.
Answer, from Anthropic's docs: Claude Code expands ${VAR} and
${VAR:-default} itself, in command, args, env, url and headers, for both
project .mcp.json and user-scope ~/.claude.json. Claude Desktop has no
documented support.

So this is a per-client capability, not a global one, and BCC does NOT
expand on write: resolving a reference into the file would put the secret
back on disk -- the whole thing the user is avoiding -- and would defeat a
feature the client already implements correctly. BCC authors, validates and
warns; expand_env_refs exists to preview what the client will do.

Semantics mirror the documented ones exactly, including the unusual bit:
an unset variable with no default is left as literal ${VAR} text rather
than blanked, because that is what Claude Code passes through.

Gating uses the existing profile_targets_claude_desktop(), so a config that
is correct under Claude Code and broken under Desktop is reported against
whichever profile is actually loaded. The two warnings are worded
differently on purpose -- 'this client will never expand these' is a
different problem from 'this variable looks unset here'.

Two existing behaviours were backwards for this feature and are fixed:

- Secret masking hid placeholders. is_secret_key('API_KEY') is true, so
  ${API_KEY} rendered as dots -- making a reference indistinguishable from
  a stored credential, which is the one distinction that makes the feature
  worth adopting. should_mask_value() now skips references, in the table
  delegate, _redact_server_data and redact_args alike.
- args_secret_warning fired on placeholders. Moving a token into ${VAR} is
  the recommended fix for that warning; continuing to warn punished the
  fix. It now skips references while still flagging a real secret that
  follows one.

Real secrets are still masked everywhere they were before -- asserted, not
assumed.

Refs #76
2026-07-20 13:46:14 -04:00
the_og 7ff4f6e5c0 Merge PR #81: make the update checker visible — persistent banner + Help menu item (#78, #79)
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
CI / Catalog signature (push) Successful in 6s
CI / Tests (py3.12 / windows-latest) (push) Has been cancelled
2026-07-20 12:52:48 -04:00
the_og 7517e16b15 Merge branch 'main' into fix/78-79-update-visibility
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Catalog signature (pull_request) Successful in 6s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
Three conflicts, two of them semantic rather than textual:

- bcc.py QSS: this branch added the noticeBanner rules using the old
  module-level constants ({MUTED}, {ACCENT}); main had since moved the
  stylesheet onto palette slots ({p.muted}). Took main's form and
  translated the notice rules into it -- picking either side wholesale
  would have either dropped the banner styling or reintroduced globals
  that test_stylesheet_builder_has_no_hardcoded_colours now forbids.
- bcc.py methods: both sides appended to MainWindow (update-notice
  handlers vs theme handlers). Additive, kept both.
- tests/test_core.py: the usual EOF append. Kept both blocks.

_build_menu_bar auto-merged cleanly (View menu above, Help menu below);
verified both are present with their menu roles intact.

Verified: 272 test functions = 265 (main) + 7 (this branch), no
duplicates; 421 passed, ruff clean.
2026-07-20 12:51:55 -04:00
the_og 9a0433225e Merge PR #80: light theme + system-following, dark preserved exactly (#75)
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 11s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
CI / Catalog signature (push) Successful in 7s
CI / Tests (py3.12 / windows-latest) (push) Has been cancelled
2026-07-20 12:51:01 -04:00
the_og fa82d30087 Merge branch 'main' into feat/75-theming
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Catalog signature (pull_request) Successful in 7s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
Union conflict at the end of tests/test_core.py -- both branches appended a
test block. Kept both, main's #72/#73 block first. Verified: 265 test
functions = 238 baseline + 15 (#77) + 12 (theming), no duplicates.
2026-07-20 12:50:09 -04:00
the_og 05b00a40c0 Merge PR #77: tolerate non-object server values; keep named sets across a merge (#72, #73)
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.13 / ubuntu-latest) (push) Successful in 11s
CI / Catalog signature (push) Successful in 7s
CI / Tests (py3.12 / windows-latest) (push) Has been cancelled
2026-07-20 12:49:23 -04:00
the_og 3068e74e5c fix: make the update checker visible -- persistent banner + a menu item
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.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Catalog signature (pull_request) Successful in 6s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
Reported from the field: running v1.2 against a repo with v1.3.0 published
gave no prompt, and there appeared to be no way to check manually. The
checker itself works; it was invisible, for two reasons.

#78 -- the notice was written to the shared status label, which 21 other
call sites rewrite. The check runs off-thread and lands a second or two
after launch, right as the user starts clicking, so the next selection or
refresh wiped it. Exactly the bug fixed for the MSIX warning in #35, which
got a persistent banner; that fix was never carried to the update notice.

Adds NoticeBanner: a persistent, dismissible notice carrying its own action
button. It's a shared widget rather than a second bespoke banner, so the
next thing needing the user's attention doesn't reach for the status bar
again. (The MSIX banner still uses its own QLabel -- migrating it is a
follow-up, deliberately not bundled with a bug fix.)

#79 -- the only 'Check for updates' affordance was a button inside the
About dialog, which is not where anyone looks. Worse, the About action was
created without a menu role, and Qt auto-assigns AboutRole to actions whose
text begins with 'About', relocating it into the macOS application menu --
so the notice's own hint, 'Help > About to view it', pointed at a menu that
on macOS doesn't contain the item.

Help now has its own 'Check for updates...' item with an explicit
ApplicationSpecificRole, and the About action states its AboutRole rather
than inheriting it invisibly. The menu-driven check is never throttled and
always reports back -- the user asked, so silence would read as broken.

The decision and the wording live in core.update_notice() because the test
suite has no PySide6 (CI installs pytest + cryptography only), so anything
in bcc.py is untestable. A test asserts the notice text names no menu path,
which is what went stale here in the first place.

Closes #78
Closes #79
2026-07-20 12:29:37 -04:00
the_og febd617c56 feat: light theme + system-following, with the dark theme preserved exactly
CI / Lint (ruff) (pull_request) Successful in 8s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 11s
CI / Catalog signature (pull_request) Successful in 6s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
BCC has always been dark-only -- BG #1b1d23, hardcoded at import time, with
no light option and no awareness of the desktop's appearance. On a light
desktop it matches nothing else on screen and there was no way to change it.

Adds a Palette value type in bcc_core with DARK (byte-identical to the
colours v1.3.0 shipped) and a new LIGHT, plus resolve_theme(setting,
system_is_dark) so the decision is testable without a Qt app. View > Theme
offers Match system / Light / Dark, persisted in QSettings under ui/theme,
defaulting to following the system.

The light palette's semantic colours are deliberately not the dark ones
lightened: #4ade80 sits near 1.7:1 against white. They are darkened to clear
WCAG AA, and a contrast test enforces >= 4.5:1 for every text colour against
its surface in both palettes so nobody harmonises them back later.

Three near-black literals were baked into the stylesheet (#1a1205 on-accent
text, #202229 disabled table, #16181d diagnostics pane). Fine with one theme,
invisible breakage with two -- each now has a palette slot, and a test
asserts build_stylesheet contains no hex literals at all.

The ~20 inline setStyleSheet(f"color: {MUTED}") call sites are left alone:
apply_palette rebinds the module-level colour names, and an f-string resolves
its names when it runs, so each call site picks up the new colour on its next
render. Switching theme reapplies the global QSS and re-renders the
inline-styled widgets, so nothing is left dark-on-light.

Refs #75
2026-07-20 12:22:38 -04:00
the_og da20eb2fdb fix: tolerate non-object server values on load; keep named sets across a merge
CI / Lint (ruff) (pull_request) Successful in 13s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 12s
CI / Catalog signature (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
Two silent-failure bugs found auditing the v1.3.0 features.

#72 -- extract_servers called dict() on every server value, so a config
that was valid JSON but held a non-object server ("foo": "oops", a
number, a list, null) raised on load. The strict parse succeeded, so the
repair path never saw it, and the call sat outside the load try/except:
an unhandled traceback with the window half-swapped to the new profile.
It also made the #54 schema lint unreachable for the most likely
hand-edit mistake -- the load died before the linter ran.

Malformed values are now preserved verbatim on ServerEntry.raw (behind a
NO_RAW sentinel, since a literal JSON null is itself a malformed entry
worth keeping) and written back untouched on Save, so nothing is silently
deleted. lint_servers names the offending entry instead.

#73 -- the stale-file "Merge & save" path reloaded the file from disk and
re-applied the user's servers, but apply_servers only writes mcpServers
and _disabledMcpServers. Named server sets live under _bccServerSets in
the same file, so a set saved that session was dropped from disk and then
from memory, with no warning, on the path the user picks because it
sounds like the safe one.

BCC-owned keys are now declared in BCC_OWNED_KEYS and carried across by
carry_owned_keys, which reports genuinely contested keys so the status
line can say so. Deliberately one-directional: a key absent locally is
left alone on disk, because 'user deleted their last set' and 'another
machine just added sets' are indistinguishable and deleting someone
else's data is the worse failure.

Closes #72
Closes #73
2026-07-20 12:11:49 -04:00
10 changed files with 1588 additions and 1812 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ jobs:
# below to the new key's base64 form, as its own reviewed change. # below to the new key's base64 form, as its own reviewed change.
- name: Verify data/catalog.json.sig - name: Verify data/catalog.json.sig
env: env:
EXPECTED_CATALOG_PUBKEY_B64: "0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k=" EXPECTED_CATALOG_PUBKEY_B64: "082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4="
run: | run: |
python - <<'PY' python - <<'PY'
import base64, os, pathlib, sys import base64, os, pathlib, sys
+3 -35
View File
@@ -90,21 +90,6 @@ key, because they protect different things and live in different places:
| Generated with | `python catalog_console.py keygen` | `python catalog_console.py keygen --release` | | Generated with | `python catalog_console.py keygen` | `python catalog_console.py keygen --release` |
| Exported for CI with | *(never — there is no supported way to export this key)* | `python catalog_console.py show-seed-b64 --release` | | Exported for CI with | *(never — there is no supported way to export this key)* | `python catalog_console.py show-seed-b64 --release` |
**Confused about which key is which, or what state either is in?** Run:
```bash
python catalog_console.py keys
```
It needs no passphrase (it never touches private key bytes) and prints a
plain-English report for both keys: where each private half lives, whether
it's present on this machine, its fingerprint, whether that fingerprint
matches what's actually committed in `bcc_core.py`, `ci.yml`'s trust
anchor, and `scripts/sign_checksums.py`, and whether
`data/catalog.json.sig` currently verifies — ending with the exact command
to run next for whatever state it finds. This is the check that would have
caught [issue #68](../../issues/68)'s finding 5 incident before it happened.
**Why two keys:** the catalog key is the root of trust for what BCC **Why two keys:** the catalog key is the root of trust for what BCC
actually *executes* on a user's machine — every `command`/`args` pair in actually *executes* on a user's machine — every `command`/`args` pair in
the shipped catalog is only there because this key signed it. If that key the shipped catalog is only there because this key signed it. If that key
@@ -125,29 +110,12 @@ refuses to run without `--release` specifically so the catalog seed can't
be exported by habit or muscle memory. be exported by habit or muscle memory.
**Release signing public key** (Ed25519, base64, raw 32 bytes) — this is **Release signing public key** (Ed25519, base64, raw 32 bytes) — this is
the RELEASE key, which signs `SHA256SUMS` (release checksums). It does the RELEASE key, not the catalog key:
**not** sign `data/catalog.json` and is not the key `bcc_core.CATALOG_PUBKEYS`
trusts:
``` ```
6BnPgJEHJFyVltFoLTCNadIsehjy00iiW8IRlC1TfhA= <PLACEHOLDER — AJ: paste the release public key from `catalog_console.py keygen --release` here>
``` ```
The catalog public key (Ed25519, base64, raw 32 bytes) — this is the key
that signs `data/catalog.json` and is trusted via `bcc_core.CATALOG_PUBKEYS`
and the CI trust anchor in `.github/workflows/ci.yml`. It is listed here
for completeness, not because you need it to verify a download — use the
*release* key above for that:
```
0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k=
```
Both keys above were rotated 2026-07 — see [issue #68](../../issues/68)
finding 5. The prior (shared) key is retired and is deliberately **not**
kept in either trust list; retaining a burned key would defeat the point
of rotating it.
## Run from source ## Run from source
```bash ```bash
@@ -222,7 +190,7 @@ file is also listed, marked *legacy*, so you can copy them over.
- `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)). - `scripts/sign_checksums.py` — generates and Ed25519-signs the release `SHA256SUMS` manifest (see [Verifying your download](#verifying-your-download)).
- `catalog_console.py` / `catalog_review.py`**maintainer-only**, never shipped to users (excluded from `bcc.spec`; see `tests/test_catalog_console_packaging.py`). The Catalog Console: review + sign `data/catalog.json` (against `main`, an open PR, or the branch you have checked out — `--ref <branch>` to be explicit, e.g. mid key-rotation, so a rotation can be signed and pushed to its own branch *before* it's merged, never forcing a red `main`), generate/manage both signing keys (`keygen`, `keygen --release`), and report on their status (`keys`, no passphrase needed) — see [Signing keys](#signing-keys). - `catalog_console.py` / `catalog_review.py`**maintainer-only**, never shipped to users (excluded from `bcc.spec`; see `tests/test_catalog_console_packaging.py`). The Catalog Console: review + sign `data/catalog.json`, and generate/manage both signing keys (`keygen`, `keygen --release`) — see [Signing keys](#signing-keys).
## Building from source ## Building from source
+292 -60
View File
@@ -10,6 +10,7 @@ Run: python mcp_manager.py
from __future__ import annotations from __future__ import annotations
import contextlib
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
@@ -18,6 +19,7 @@ from typing import ClassVar
from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal
from PySide6.QtGui import ( from PySide6.QtGui import (
QAction, QAction,
QActionGroup,
QColor, QColor,
QCursor, QCursor,
QDesktopServices, QDesktopServices,
@@ -25,6 +27,7 @@ from PySide6.QtGui import (
QIcon, QIcon,
QKeySequence, QKeySequence,
QPainter, QPainter,
QPalette,
QPixmap, QPixmap,
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@@ -65,85 +68,122 @@ import bcc_core as core
# thread during drag-and-drop import, so skip anything larger than this. # thread during drag-and-drop import, so skip anything larger than this.
MAX_DROP_IMPORT_BYTES = 5 * 1024 * 1024 # 5 MB MAX_DROP_IMPORT_BYTES = 5 * 1024 * 1024 # 5 MB
# --- One-line rebrand: change this to recolor the whole app --------------- # # --- Theming (issue #75) -------------------------------------------------- #
ACCENT = "#f97316" # warm orange # The palette lives in bcc_core (testable without a Qt app); these module-level
ACCENT_DIM = "#c2570b" # names are rebound by `apply_palette()` whenever the theme changes.
BG = "#1b1d23" #
PANEL = "#23262e" # Why globals rather than passing a palette around: ~20 inline
PANEL_2 = "#2b2f39" # `setStyleSheet(f"color: {MUTED}")` calls are scattered through this file, and
TEXT = "#e7e9ee" # an f-string resolves its names when it runs, not when it's compiled. Rebinding
MUTED = "#9aa0ad" # the globals means every one of those call sites picks up the new colour on its
BORDER = "#3a3f4b" # next render, with no change to the call sites themselves.
GOOD = "#4ade80" PALETTE = core.DARK_PALETTE
BAD = "#f87171" ACCENT = ACCENT_DIM = BG = PANEL = PANEL_2 = TEXT = MUTED = BORDER = ""
WARN = "#fbbf24" GOOD = BAD = WARN = REMOTE = ON_ACCENT = DISABLED_BG = MONO_BG = SEL_TEXT = ""
STATUS_COLORS: dict[str, str] = {}
HEALTH_COLORS: dict[str, str] = {}
STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": "#60a5fa", "unknown": WARN} STATUS_GLYPH = {
STATUS_GLYPH = {"ok": "", "missing": "", "warn": "", "remote": "", "unknown": ""} "ok": "\u25cf",
"missing": "\u25cf",
"warn": "\u25b2",
"remote": "\u25c6",
"unknown": "\u25cb",
}
# Health dot (spawn-test outcome, see core.HealthStatus) shown per row in the # Health dot (spawn-test outcome, see core.HealthStatus) shown per row in the
# server tables' "Health" column -- distinct from the PATH-dependency Status # server tables' "Health" column -- distinct from the PATH-dependency Status
# column above. # column above.
HEALTH_COLORS = {"ok": GOOD, "failed": BAD, "untested": MUTED} HEALTH_GLYPH = {"ok": "\u25cf", "failed": "\u25cf", "untested": "\u25cb"}
HEALTH_GLYPH = {"ok": "", "failed": "", "untested": ""}
STYLESHEET = f"""
def build_stylesheet(p: core.Palette) -> str:
"""Render the global QSS for a palette."""
return f"""
/* No font-family here on purpose: Qt already uses the native system UI font /* No font-family here on purpose: Qt already uses the native system UI font
on every platform (San Francisco / Segoe UI / desktop default). Naming on every platform (San Francisco / Segoe UI / desktop default). Naming
web-CSS aliases like -apple-system forces a costly font-alias scan. */ web-CSS aliases like -apple-system forces a costly font-alias scan. */
* {{ font-size: 13px; color: {TEXT}; }} * {{ font-size: 13px; color: {p.text}; }}
QMainWindow, QDialog {{ background: {BG}; }} QMainWindow, QDialog {{ background: {p.bg}; }}
QLabel#h1 {{ font-size: 15px; font-weight: 600; }} QLabel#h1 {{ font-size: 15px; font-weight: 600; }}
QLabel#muted {{ color: {MUTED}; }} QLabel#muted {{ color: {p.muted}; }}
QFrame#card {{ background: {PANEL}; border: 1px solid {BORDER}; border-radius: 10px; }} QFrame#card {{ background: {p.panel}; border: 1px solid {p.border}; border-radius: 10px; }}
QLineEdit, QPlainTextEdit, QComboBox {{ QLineEdit, QPlainTextEdit, QComboBox {{
background: {PANEL_2}; border: 1px solid {BORDER}; border-radius: 7px; background: {p.panel_2}; border: 1px solid {p.border}; border-radius: 7px;
padding: 6px 8px; selection-background-color: {ACCENT}; selection-color: #1a1205; padding: 6px 8px; selection-background-color: {p.accent}; selection-color: {p.on_accent};
}} }}
QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus {{ border: 1px solid {ACCENT}; }} QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus {{ border: 1px solid {p.accent}; }}
QComboBox::drop-down {{ border: none; width: 22px; }} QComboBox::drop-down {{ border: none; width: 22px; }}
QComboBox QAbstractItemView {{ background: {PANEL_2}; border: 1px solid {BORDER}; QComboBox QAbstractItemView {{ background: {p.panel_2}; border: 1px solid {p.border};
selection-background-color: {ACCENT}; outline: none; }} selection-background-color: {p.accent}; outline: none; }}
QPushButton {{ background: {PANEL_2}; border: 1px solid {BORDER}; border-radius: 7px; QPushButton {{ background: {p.panel_2}; border: 1px solid {p.border}; border-radius: 7px;
padding: 7px 13px; }} padding: 7px 13px; }}
QPushButton:hover {{ border: 1px solid {ACCENT}; }} QPushButton:hover {{ border: 1px solid {p.accent}; }}
QPushButton:disabled {{ color: {MUTED}; background: {PANEL}; }} QPushButton:disabled {{ color: {p.muted}; background: {p.panel}; }}
QPushButton#primary {{ background: {ACCENT}; border: 1px solid {ACCENT}; color: #1a1205; font-weight: 600; }} QPushButton#primary {{ background: {p.accent}; border: 1px solid {p.accent}; color: {p.on_accent}; font-weight: 600; }}
QPushButton#primary:hover {{ background: {ACCENT_DIM}; }} QPushButton#primary:hover {{ background: {p.accent_dim}; }}
QPushButton#primary:disabled {{ background: {PANEL}; color: {MUTED}; border: 1px solid {BORDER}; }} QPushButton#primary:disabled {{ background: {p.panel}; color: {p.muted}; border: 1px solid {p.border}; }}
QPushButton#danger:hover {{ border: 1px solid {BAD}; color: {BAD}; }} QPushButton#danger:hover {{ border: 1px solid {p.bad}; color: {p.bad}; }}
QTableWidget {{ background: {PANEL}; border: 1px solid {BORDER}; border-radius: 10px; QTableWidget {{ background: {p.panel}; border: 1px solid {p.border}; border-radius: 10px;
gridline-color: transparent; outline: none; }} gridline-color: transparent; outline: none; }}
QTableWidget::item {{ padding: 6px 8px; border: none; }} QTableWidget::item {{ padding: 6px 8px; border: none; }}
QTableWidget::item:selected {{ background: {ACCENT}; color: #1a1205; }} QTableWidget::item:selected {{ background: {p.accent}; color: {p.on_accent}; }}
/* Inline cell editors: the global QLineEdit padding/radius clips the text /* Inline cell editors: the global QLineEdit padding/radius clips the text
inside a table row, so give editors a compact, flat style instead. */ inside a table row, so give editors a compact, flat style instead. */
QTableWidget QLineEdit {{ QTableWidget QLineEdit {{
background: {PANEL_2}; color: {TEXT}; border: 1px solid {ACCENT}; background: {p.panel_2}; color: {p.text}; border: 1px solid {p.accent};
border-radius: 3px; padding: 0px 4px; margin: 0px; border-radius: 3px; padding: 0px 4px; margin: 0px;
selection-background-color: {ACCENT_DIM}; selection-color: #ffffff; selection-background-color: {p.accent_dim}; selection-color: {p.selection_text};
}} }}
QHeaderView::section {{ background: {PANEL}; color: {MUTED}; border: none; QHeaderView::section {{ background: {p.panel}; color: {p.muted}; border: none;
border-bottom: 1px solid {BORDER}; padding: 8px; font-weight: 600; }} border-bottom: 1px solid {p.border}; padding: 8px; font-weight: 600; }}
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }} QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }}
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 24px; }} QScrollBar::handle:vertical {{ background: {p.border}; border-radius: 5px; min-height: 24px; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }} QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
QLabel#statusbar {{ color: {MUTED}; padding: 4px 2px; }} QLabel#statusbar {{ color: {p.muted}; padding: 4px 2px; }}
QLabel#warnBanner {{ color: #1a1205; background: {WARN}; border-radius: 8px; padding: 8px 10px; font-weight: 600; }} QLabel#warnBanner {{ color: {p.on_accent}; background: {p.warn}; border-radius: 8px; padding: 8px 10px; font-weight: 600; }}
QLabel#section {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }} QFrame#noticeBanner {{ background: {p.panel_2}; border: 1px solid {p.accent}; border-radius: 8px; }}
QLabel#sectionDisabled {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }} QLabel#noticeText {{ color: {p.text}; }}
QLabel#placeholder {{ color: {MUTED}; padding: 12px; background: {PANEL_2}; border: 1px dashed {BORDER}; border-radius: 8px; }} QPushButton#noticeClose {{ background: transparent; border: none; color: {p.muted}; font-size: 14px; padding: 2px; }}
QTableWidget#disabledTable {{ background: #202229; }} QPushButton#noticeClose:hover {{ color: {p.text}; }}
QTableWidget#disabledTable::item:selected {{ background: {ACCENT}; color: #1a1205; }} QLabel#section {{ color: {p.muted}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
QLabel#sectionDisabled {{ color: {p.muted}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
QLabel#placeholder {{ color: {p.muted}; padding: 12px; background: {p.panel_2}; border: 1px dashed {p.border}; border-radius: 8px; }}
QTableWidget#disabledTable {{ background: {p.disabled_bg}; }}
QTableWidget#disabledTable::item:selected {{ background: {p.accent}; color: {p.on_accent}; }}
QPlainTextEdit#diag {{ font-family: "Menlo", "Cascadia Code", "Consolas", "DejaVu Sans Mono", monospace; QPlainTextEdit#diag {{ font-family: "Menlo", "Cascadia Code", "Consolas", "DejaVu Sans Mono", monospace;
font-size: 12px; background: #16181d; border: 1px solid {BORDER}; border-radius: 8px; }} font-size: 12px; background: {p.mono_bg}; border: 1px solid {p.border}; border-radius: 8px; }}
QFrame#diagCard {{ background: transparent; border: none; }} QFrame#diagCard {{ background: transparent; border: none; }}
QSplitter::handle {{ background: transparent; }} QSplitter::handle {{ background: transparent; }}
QSplitter::handle:hover {{ background: {BORDER}; border-radius: 4px; }} QSplitter::handle:hover {{ background: {p.border}; border-radius: 4px; }}
QSplitter::handle:pressed {{ background: {ACCENT}; border-radius: 4px; }} QSplitter::handle:pressed {{ background: {p.accent}; border-radius: 4px; }}
""" """
def apply_palette(p: core.Palette) -> str:
"""Rebind the module-level colour names to `p` and return its stylesheet."""
global PALETTE, ACCENT, ACCENT_DIM, BG, PANEL, PANEL_2, TEXT, MUTED, BORDER
global GOOD, BAD, WARN, REMOTE, ON_ACCENT, DISABLED_BG, MONO_BG, SEL_TEXT
global STATUS_COLORS, HEALTH_COLORS
PALETTE = p
ACCENT, ACCENT_DIM = p.accent, p.accent_dim
BG, PANEL, PANEL_2 = p.bg, p.panel, p.panel_2
TEXT, MUTED, BORDER = p.text, p.muted, p.border
GOOD, BAD, WARN, REMOTE = p.good, p.bad, p.warn, p.remote
ON_ACCENT, DISABLED_BG, MONO_BG, SEL_TEXT = (
p.on_accent,
p.disabled_bg,
p.mono_bg,
p.selection_text,
)
STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": REMOTE, "unknown": WARN}
HEALTH_COLORS = {"ok": GOOD, "failed": BAD, "untested": MUTED}
return build_stylesheet(p)
STYLESHEET = apply_palette(core.DARK_PALETTE)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Background reachability tester (keeps the UI responsive during the request) # Background reachability tester (keeps the UI responsive during the request)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -228,7 +268,7 @@ class _SecretMaskDelegate(QStyledItemDelegate):
if self.revealed or not option.text: if self.revealed or not option.text:
return return
key_item = self._table.item(index.row(), 0) key_item = self._table.item(index.row(), 0)
if key_item and core.is_secret_key(key_item.text()): if key_item and core.should_mask_value(key_item.text(), option.text):
option.text = core.MASK option.text = core.MASK
@@ -1493,6 +1533,54 @@ class AboutDialog(QDialog):
QDesktopServices.openUrl(QUrl(self._release_url or core.RELEASES_URL)) QDesktopServices.openUrl(QUrl(self._release_url or core.RELEASES_URL))
class NoticeBanner(QFrame):
"""A persistent, dismissible notice with an optional action button.
The status bar is the wrong home for anything the user needs to act on --
21 call sites rewrite it, so a message posted there is gone by the next
click. That wiped the MSIX warning (#35) and then the update notice (#78).
This is the shared mechanism so it doesn't happen a third time.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("noticeBanner")
row = QHBoxLayout(self)
row.setContentsMargins(10, 8, 8, 8)
row.setSpacing(8)
self._label = QLabel("")
self._label.setObjectName("noticeText")
self._label.setWordWrap(True)
row.addWidget(self._label, 1)
self._action_btn = QPushButton("")
self._action_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._action_btn.hide()
row.addWidget(self._action_btn)
self._close_btn = QPushButton("\u2715")
self._close_btn.setObjectName("noticeClose")
self._close_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._close_btn.setFixedWidth(26)
self._close_btn.setToolTip("Dismiss")
self._close_btn.clicked.connect(self.hide)
row.addWidget(self._close_btn)
self.hide()
def show_notice(self, text: str, action_label: str = "", on_action=None):
self._label.setText(text)
self._label.setToolTip(text)
# Reconnect cleanly: a banner reused for a second notice would
# otherwise fire the previous notice's action too.
with contextlib.suppress(RuntimeError, TypeError):
self._action_btn.clicked.disconnect()
if action_label and on_action is not None:
self._action_btn.setText(action_label)
self._action_btn.clicked.connect(lambda _=False: on_action())
self._action_btn.show()
else:
self._action_btn.hide()
self.show()
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Restart worker: core.restart_claude_desktop() blocks up to ~5 s on macOS # Restart worker: core.restart_claude_desktop() blocks up to ~5 s on macOS
# waiting for the old instance to exit, so it must run off the UI thread. # waiting for the old instance to exit, so it must run off the UI thread.
@@ -1550,6 +1638,11 @@ class MainWindow(QMainWindow):
self.warn_banner.hide() self.warn_banner.hide()
root.addWidget(self.warn_banner) root.addWidget(self.warn_banner)
# Update availability gets its own persistent banner rather than a
# status-line write, which the next UI action overwrites (#78).
self.update_banner = NoticeBanner(self)
root.addWidget(self.update_banner)
# User-draggable divider between the server list and the editor. # User-draggable divider between the server list and the editor.
split = QSplitter(Qt.Orientation.Horizontal) split = QSplitter(Qt.Orientation.Horizontal)
split.setChildrenCollapsible(False) split.setChildrenCollapsible(False)
@@ -1585,11 +1678,96 @@ class MainWindow(QMainWindow):
# --- menu bar ---------------------------------------------------------- # # --- menu bar ---------------------------------------------------------- #
def _build_menu_bar(self): def _build_menu_bar(self):
view_menu = self.menuBar().addMenu("&View")
theme_menu = view_menu.addMenu("Theme")
self._theme_group = QActionGroup(self)
self._theme_group.setExclusive(True)
current = stored_theme_setting()
for setting, label in (
(core.THEME_SYSTEM, "Match system"),
(core.THEME_LIGHT, "Light"),
(core.THEME_DARK, "Dark"),
):
act = QAction(label, self, checkable=True)
act.setChecked(setting == current)
act.triggered.connect(lambda _checked=False, s=setting: self._set_theme(s))
self._theme_group.addAction(act)
theme_menu.addAction(act)
help_menu = self.menuBar().addMenu("&Help") help_menu = self.menuBar().addMenu("&Help")
# "Check for updates" used to exist only as a button inside the About
# dialog, which is not somewhere anyone looks for it (#79).
update_action = QAction("Check for updates…", self)
# Explicit role: macOS relocates actions it recognises by text, and
# some Qt versions treat "update" as application-menu material. Pin it
# so the item stays where the menu says it is on every platform.
update_action.setMenuRole(QAction.MenuRole.ApplicationSpecificRole)
update_action.triggered.connect(self.check_for_updates)
help_menu.addAction(update_action)
help_menu.addSeparator()
about_action = QAction("About Better Claude Config…", self) about_action = QAction("About Better Claude Config…", self)
# Qt auto-assigns AboutRole to actions whose text starts with "About",
# which moves this into the application menu on macOS. That is the
# right home there -- state it explicitly rather than inheriting it by
# accident, since the behaviour is invisible from this call site.
about_action.setMenuRole(QAction.MenuRole.AboutRole)
about_action.triggered.connect(self._show_about) about_action.triggered.connect(self._show_about)
help_menu.addAction(about_action) help_menu.addAction(about_action)
def _show_update_notice(self, notice: dict):
"""Surface an available update where it survives the next click."""
url = notice["url"]
self.update_banner.show_notice(
notice["text"],
action_label="Open releases page",
on_action=lambda: QDesktopServices.openUrl(QUrl(url)),
)
def check_for_updates(self):
"""Menu-driven check. Unlike the startup check this is never throttled
and always reports back -- the user asked, so silence would read as a
broken button."""
self.status.setText("Checking for updates…")
self._menu_update_worker = UpdateCheckWorker()
self._menu_update_worker.done.connect(self._on_menu_update_checked)
self._menu_update_worker.start()
def _on_menu_update_checked(self, release: dict | None):
self._menu_update_worker = None
if release is None:
self.status.setText("Couldn't check for updates (offline?).")
return
QSettings("BCC", "BetterClaudeConfig").setValue("update/lastCheck", time.time())
notice = core.update_notice(core.__version__, release)
if notice:
self._show_update_notice(notice)
self.status.setText(f"Update available: {notice['version']}")
else:
self.update_banner.hide()
self.status.setText(f"You're up to date ({core.__version__}).")
def _set_theme(self, setting: str):
"""Persist the theme choice and repaint the running window."""
QSettings("BCC", "BetterClaudeConfig").setValue("ui/theme", setting)
app = QApplication.instance()
if app is None: # pragma: no cover - only in a headless test harness
return
app.setStyleSheet(theme_stylesheet_for(app, setting))
# The global stylesheet covers most of the UI, but the inline
# setStyleSheet calls (status dots, warning labels, update banner) only
# pick up the new palette when their widget next renders -- so re-render
# them now rather than leaving dark-on-light text behind.
self._repaint_themed_widgets()
def _repaint_themed_widgets(self):
"""Re-run the inline-styled bits after a palette change."""
self.status.setStyleSheet(f"color: {MUTED};")
idx = self._current_index()
self._refresh_tables(select_index=idx if idx >= 0 else -1)
self._update_status(saved=False)
def _show_about(self): def _show_about(self):
AboutDialog(self).exec() AboutDialog(self).exec()
@@ -1610,10 +1788,9 @@ class MainWindow(QMainWindow):
if release is None: if release is None:
return # offline/failed check: don't advance lastCheck, allow retry return # offline/failed check: don't advance lastCheck, allow retry
QSettings("BCC", "BetterClaudeConfig").setValue("update/lastCheck", time.time()) QSettings("BCC", "BetterClaudeConfig").setValue("update/lastCheck", time.time())
if core.is_newer_version(core.__version__, release["version"]): notice = core.update_notice(core.__version__, release)
self.status.setText( if notice:
f"Update available: {release['version']} · Help ▸ About to view it." self._show_update_notice(notice)
)
# --- layout persistence ---------------------------------------------- # # --- layout persistence ---------------------------------------------- #
def _restore_layout(self): def _restore_layout(self):
@@ -1939,9 +2116,21 @@ class MainWindow(QMainWindow):
return return
self.full_config = cfg self.full_config = cfg
repaired = True repaired = True
# extract_servers tolerates malformed entries rather than raising (#72),
# but keep it inside the guard: a load failure must leave the previously
# loaded profile intact instead of half-swapping the window's state.
try:
servers = core.extract_servers(self.full_config)
except Exception as exc: # pragma: no cover - defence in depth
QMessageBox.critical(
self,
"Could not read config",
f"{profile.path}\n\nThe server list couldn't be read: {exc}",
)
return
self._loaded_stat = core.config_fingerprint(profile.path) self._loaded_stat = core.config_fingerprint(profile.path)
self.current_profile = profile self.current_profile = profile
self.servers = core.extract_servers(self.full_config) self.servers = servers
self.dirty = False self.dirty = False
self.restart_btn.hide() self.restart_btn.hide()
self._undo_stack.clear() self._undo_stack.clear()
@@ -2264,7 +2453,7 @@ class MainWindow(QMainWindow):
entry = self.servers[idx] entry = self.servers[idx]
old_name = entry.name old_name = entry.name
entry.name = self.editor.current_name() entry.name = self.editor.current_name()
entry.data = self.editor.dump_data() entry.set_data(self.editor.dump_data())
# The server stays in its section (enable state unchanged), so update # The server stays in its section (enable state unchanged), so update
# its existing row in place rather than re-rendering. # its existing row in place rather than re-rendering.
# An edit invalidates any cached "Test all" result -- the server that # An edit invalidates any cached "Test all" result -- the server that
@@ -2358,7 +2547,7 @@ class MainWindow(QMainWindow):
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if ans == QMessageBox.StandardButton.Yes: if ans == QMessageBox.StandardButton.Yes:
self.servers[existing[name]].data = data self.servers[existing[name]].set_data(data)
return False, True return False, True
name = core.resolve_name_collision(name, {s.name for s in self.servers}) name = core.resolve_name_collision(name, {s.name for s in self.servers})
self.servers.append(core.ServerEntry(name, data, True)) self.servers.append(core.ServerEntry(name, data, True))
@@ -2433,6 +2622,12 @@ class MainWindow(QMainWindow):
self.save_btn.setEnabled(False) self.save_btn.setEnabled(False)
return False return False
lint_warnings = core.lint_servers(self.servers) lint_warnings = core.lint_servers(self.servers)
# ${VAR} references are only meaningful if the target client expands
# them -- Claude Desktop doesn't, so the same config is fine in one
# profile and broken in another (#76). Report against the loaded one.
for entry in self.servers:
for warning in core.env_ref_warnings(entry.data, self.current_profile):
lint_warnings.append(f"'{entry.name}': {warning}")
if lint_warnings: if lint_warnings:
self.validation_lbl.setText(f"{lint_warnings[0]}") self.validation_lbl.setText(f"{lint_warnings[0]}")
self.validation_lbl.setStyleSheet(f"color: {WARN};") self.validation_lbl.setStyleSheet(f"color: {WARN};")
@@ -2478,6 +2673,11 @@ class MainWindow(QMainWindow):
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Reload failed", str(e)) QMessageBox.critical(self, "Reload failed", str(e))
return return
# The reload above is the on-disk truth for everything the user
# didn't touch -- but it also wipes BCC-authored keys the user
# changed in this session (named sets), which apply_servers
# doesn't write. Carry them over before saving (#73).
contested = core.carry_owned_keys(self.full_config, fresh)
core.apply_servers(fresh, self.servers) core.apply_servers(fresh, self.servers)
try: try:
backup = core.write_config(self.current_profile.path, fresh) backup = core.write_config(self.current_profile.path, fresh)
@@ -2490,8 +2690,13 @@ class MainWindow(QMainWindow):
self.dirty = False self.dirty = False
self.save_btn.setEnabled(False) self.save_btn.setEnabled(False)
bnote = f" · backup: {backup.name}" if backup else " · (new file)" bnote = f" · backup: {backup.name}" if backup else " · (new file)"
cnote = (
f" · kept your {', '.join(contested)} (the file on disk had a different copy)"
if contested
else ""
)
self.status.setText( self.status.setText(
f"Merged & saved {self.current_profile.path}{bnote}" f"Merged & saved {self.current_profile.path}{bnote}{cnote}"
f" · Restart {self.current_profile.label} to apply." f" · Restart {self.current_profile.label} to apply."
) )
self._offer_restart_button() self._offer_restart_button()
@@ -2649,6 +2854,33 @@ class MainWindow(QMainWindow):
e.accept() e.accept()
def system_is_dark(app: QApplication) -> bool:
"""Whether the desktop is currently using a dark appearance.
Read from the style's own window colour rather than per-platform APIs --
Qt has already resolved the OS appearance by the time it builds the
default palette, so this works the same on all three platforms.
"""
try:
return app.palette().color(QPalette.ColorRole.Window).lightness() < 128
except Exception: # pragma: no cover - defensive; never block startup on theming
return True
def stored_theme_setting() -> str:
"""The user's theme choice, defaulting to following the system."""
value = QSettings("BCC", "BetterClaudeConfig").value("ui/theme", core.THEME_SYSTEM)
return value if value in core.THEME_CHOICES else core.THEME_SYSTEM
def theme_stylesheet_for(app: QApplication, setting: str | None = None) -> str:
"""Resolve setting + OS appearance into a palette, apply it, return the QSS."""
if setting is None:
setting = stored_theme_setting()
theme = core.resolve_theme(setting, system_is_dark(app))
return apply_palette(core.palette_for(theme))
def main(): def main():
if sys.platform == "win32": if sys.platform == "win32":
# Without an explicit AppUserModelID, Windows taskbar groups the app # Without an explicit AppUserModelID, Windows taskbar groups the app
@@ -2667,7 +2899,7 @@ def main():
icon = _app_icon() icon = _app_icon()
if not icon.isNull(): if not icon.isNull():
app.setWindowIcon(icon) app.setWindowIcon(icon)
app.setStyleSheet(STYLESHEET) app.setStyleSheet(theme_stylesheet_for(app))
win = MainWindow() win = MainWindow()
win.show() win.show()
sys.exit(app.exec()) sys.exit(app.exec())
+460 -10
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import base64 import base64
import contextlib import contextlib
import copy
import difflib import difflib
import functools import functools
import glob import glob
@@ -49,6 +50,12 @@ DISABLED_KEY = "_disabledMcpServers"
# parks the rest under DISABLED_KEY. # parks the rest under DISABLED_KEY.
SETS_KEY = "_bccServerSets" SETS_KEY = "_bccServerSets"
# Top-level keys BCC itself authors. They live in the client's config file, but
# BCC is their owner, so on a stale-file merge the in-memory copy wins over the
# on-disk one (see `carry_owned_keys`). Any future BCC-authored key belongs
# here -- forgetting to add one is exactly how #73 happened.
BCC_OWNED_KEYS = (SETS_KEY,)
BACKUP_DIRNAME = ".bcc_backups" BACKUP_DIRNAME = ".bcc_backups"
MAX_BACKUPS = 15 MAX_BACKUPS = 15
@@ -163,6 +170,39 @@ def fetch_latest_release(timeout: float = 4.0) -> dict | None:
return {"version": tag, "url": payload.get("html_url") or RELEASES_URL} return {"version": tag, "url": payload.get("html_url") or RELEASES_URL}
def update_notice(
current: str, release: dict | None, url_fallback: str = RELEASES_URL
) -> dict | None:
"""Decide whether to tell the user about a release, and what to say.
Returns {"version", "text", "url"} when `release` is newer than `current`,
or None when it isn't, when the check failed, or when the payload is
malformed. Kept here rather than in the GUI so the wording and the
should-we-notify decision are testable -- bcc.py can't be imported by the
test suite, which has no PySide6.
The text deliberately names no menu path. The old status-line notice read
"Help > About to view it", which is wrong on macOS: Qt relocates the About
action into the application menu (#79). A notice that carries its own
action can't drift out of sync with the platform.
"""
if not isinstance(release, dict):
return None
version = release.get("version")
if not version or not isinstance(version, str):
return None
if not is_newer_version(current, version):
return None
# Tags carry a "v" prefix and __version__ doesn't; render both the same way
# so the notice doesn't read "Version v1.3.0 ... you're running 1.2.0".
shown = version.lstrip("vV")
return {
"version": version,
"text": f"Version {shown} is available. You're running {current.lstrip('vV')}.",
"url": release.get("url") or url_fallback,
}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Data model # Data model
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -178,16 +218,195 @@ class Profile:
self.path = Path(self.path) self.path = Path(self.path)
class _NoRaw:
"""Sentinel for ServerEntry.raw.
`None` can't do this job: `{"mcpServers": {"foo": null}}` is legal JSON and
a real malformed-entry case, so None has to mean "the config said null",
not "there was nothing here".
"""
__slots__ = ()
def __repr__(self) -> str: # keeps ServerEntry reprs readable in test output
return "<no raw>"
NO_RAW = _NoRaw()
@dataclass @dataclass
class ServerEntry: class ServerEntry:
"""One server definition.
`data` is always a dict so every consumer can treat it as one. When the
config held something that wasn't a JSON object for this server (a string,
a number, a list -- all legal JSON, all wrong here), `data` is empty and
the original value is preserved verbatim in `raw` so Save round-trips it
instead of silently deleting the user's line. `lint_servers` surfaces it.
`raw` defaults to the NO_RAW sentinel rather than None, because a config
value of literal `null` is itself a malformed entry worth preserving.
Assigning `data` means the user replaced the definition through the editor,
which retires `raw` -- use `set_data` so that can't be forgotten.
"""
name: str name: str
data: dict data: dict
enabled: bool = True enabled: bool = True
raw: object = NO_RAW
@property @property
def kind(self) -> str: def kind(self) -> str:
return "remote" if "url" in self.data and "command" not in self.data else "stdio" return "remote" if "url" in self.data and "command" not in self.data else "stdio"
@property
def malformed(self) -> bool:
"""True when the config value for this server wasn't a JSON object."""
return self.raw is not NO_RAW
def set_data(self, data: dict) -> None:
"""Replace the definition from the editor, clearing any malformed original."""
self.data = data
self.raw = NO_RAW
def config_value(self):
"""What to write back to the config: the edited dict, or the untouched
malformed original when the user never edited it."""
return self.data if self.raw is NO_RAW else self.raw
# --------------------------------------------------------------------------- #
# Theming (issue #75)
# --------------------------------------------------------------------------- #
THEME_SYSTEM = "system"
THEME_LIGHT = "light"
THEME_DARK = "dark"
THEME_CHOICES = (THEME_SYSTEM, THEME_LIGHT, THEME_DARK)
@dataclass(frozen=True)
class Palette:
"""Every colour the UI draws with.
Deliberately exhaustive: the stylesheet used to inline a handful of
near-black literals (`#1a1205` for text on the accent, `#202229` for the
disabled table, `#16181d` for the diagnostics pane), which is fine while
there's one theme and invisible breakage the moment there are two. Each
gets a slot here so a light palette can't silently inherit a dark value.
"""
name: str
accent: str
accent_dim: str
bg: str
panel: str
panel_2: str
text: str
muted: str
border: str
good: str
bad: str
warn: str
remote: str
on_accent: str # text drawn on top of an accent fill
disabled_bg: str # the parked-servers table
mono_bg: str # diagnostics / log panes
selection_text: str
# The shipping theme through v1.3.0. These values are carried over verbatim --
# adding a light theme must not restyle the dark one.
DARK_PALETTE = Palette(
name="dark",
accent="#f97316",
accent_dim="#c2570b",
bg="#1b1d23",
panel="#23262e",
panel_2="#2b2f39",
text="#e7e9ee",
muted="#9aa0ad",
border="#3a3f4b",
good="#4ade80",
bad="#f87171",
warn="#fbbf24",
remote="#60a5fa",
on_accent="#1a1205",
disabled_bg="#202229",
mono_bg="#16181d",
selection_text="#ffffff",
)
# The semantic colours are NOT the dark ones lightened. #4ade80 / #fbbf24 sit
# around 1.7:1 against white -- illegible. These are darkened to clear 4.5:1,
# which `test_light_palette_meets_contrast` enforces so nobody "tidies" them
# back toward the dark hues later.
LIGHT_PALETTE = Palette(
name="light",
accent="#c2410c",
accent_dim="#9a3412",
bg="#f6f7f9",
panel="#ffffff",
panel_2="#eef0f4",
text="#1b1d23",
muted="#5c6270",
border="#d3d7de",
good="#15803d",
bad="#b91c1c",
warn="#a16207",
remote="#1d4ed8",
on_accent="#ffffff",
disabled_bg="#e9ebef",
mono_bg="#f0f2f5",
selection_text="#ffffff",
)
PALETTES = {DARK_PALETTE.name: DARK_PALETTE, LIGHT_PALETTE.name: LIGHT_PALETTE}
def resolve_theme(setting: str, system_is_dark: bool) -> str:
"""Map a stored theme setting + the OS appearance onto a concrete palette name.
Anything unrecognised (a hand-edited QSettings value, a setting written by
a future version) falls back to following the system rather than to a
fixed theme -- the user's desktop is the better guess.
"""
if setting == THEME_DARK:
return THEME_DARK
if setting == THEME_LIGHT:
return THEME_LIGHT
return THEME_DARK if system_is_dark else THEME_LIGHT
def palette_for(theme: str) -> Palette:
"""Concrete palette by name; unknown names fall back to dark (the historical look)."""
return PALETTES.get(theme, DARK_PALETTE)
def _hex_to_rgb(value: str) -> tuple[int, int, int]:
v = value.lstrip("#")
if len(v) == 3:
v = "".join(ch * 2 for ch in v)
return int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16)
def relative_luminance(color: str) -> float:
"""WCAG relative luminance for a #rrggbb colour."""
def chan(c: int) -> float:
srgb = c / 255.0
return srgb / 12.92 if srgb <= 0.04045 else ((srgb + 0.055) / 1.055) ** 2.4
r, g, b = (chan(c) for c in _hex_to_rgb(color))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast_ratio(fg: str, bg: str) -> float:
"""WCAG contrast ratio between two #rrggbb colours (1.0 to 21.0)."""
a, b = relative_luminance(fg), relative_luminance(bg)
lighter, darker = max(a, b), min(a, b)
return (lighter + 0.05) / (darker + 0.05)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Discovery # Discovery
@@ -452,13 +671,30 @@ def repair_config_file(path: str | os.PathLike) -> tuple[dict, list[str], str]:
return obj, notes, pretty return obj, notes, pretty
def _server_entry(name: str, data, enabled: bool) -> ServerEntry:
"""Build a ServerEntry, tolerating a value that isn't a JSON object.
A hand-edited config can legally hold `{"mcpServers": {"foo": "oops"}}` --
valid JSON, wrong shape. Calling dict() on that raises, which used to take
the whole load down before the linter ever got a look at it (#72). Keep the
original instead and let the linter report it.
"""
if isinstance(data, dict):
return ServerEntry(name=name, data=dict(data), enabled=enabled)
return ServerEntry(name=name, data={}, enabled=enabled, raw=data)
def extract_servers(cfg: dict) -> list[ServerEntry]: def extract_servers(cfg: dict) -> list[ServerEntry]:
"""Pull enabled (`mcpServers`) and disabled (`_disabledMcpServers`) servers.""" """Pull enabled (`mcpServers`) and disabled (`_disabledMcpServers`) servers.
Never raises on a structurally-odd config -- malformed entries come back as
empty-data entries carrying their original value (see `_server_entry`).
"""
out: list[ServerEntry] = [] out: list[ServerEntry] = []
for name, data in (cfg.get("mcpServers") or {}).items(): for name, data in (cfg.get("mcpServers") or {}).items():
out.append(ServerEntry(name=name, data=dict(data), enabled=True)) out.append(_server_entry(name, data, True))
for name, data in (cfg.get(DISABLED_KEY) or {}).items(): for name, data in (cfg.get(DISABLED_KEY) or {}).items():
out.append(ServerEntry(name=name, data=dict(data), enabled=False)) out.append(_server_entry(name, data, False))
return out return out
@@ -550,8 +786,8 @@ def apply_servers(cfg: dict, servers: list[ServerEntry]) -> dict:
Write the server list back into `cfg` in place, preserving every other key Write the server list back into `cfg` in place, preserving every other key
and the position of `mcpServers`. Returns the same dict for convenience. and the position of `mcpServers`. Returns the same dict for convenience.
""" """
enabled = {s.name: s.data for s in servers if s.enabled} enabled = {s.name: s.config_value() for s in servers if s.enabled}
disabled = {s.name: s.data for s in servers if not s.enabled} disabled = {s.name: s.config_value() for s in servers if not s.enabled}
cfg["mcpServers"] = enabled # replaces value if key existed; appends otherwise cfg["mcpServers"] = enabled # replaces value if key existed; appends otherwise
if disabled: if disabled:
@@ -564,6 +800,34 @@ def apply_servers(cfg: dict, servers: list[ServerEntry]) -> dict:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Write (atomic, with rotating backups) # Write (atomic, with rotating backups)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def carry_owned_keys(local_cfg: dict, fresh_cfg: dict) -> list[str]:
"""Carry BCC-authored top-level keys from `local_cfg` onto `fresh_cfg`.
Used by the stale-file "Merge & save" path, which reloads the file from
disk and re-applies the user's server edits. That reload used to drop
anything BCC owns but `apply_servers` doesn't write -- named server sets
vanished without a word (#73). BCC owns these keys, so the in-memory copy
wins; mutates `fresh_cfg` in place.
Returns the keys where the on-disk copy differed and was overwritten, so
the caller can tell the user something was actually contested rather than
merely carried across.
Deliberately one-directional: a key absent locally is left alone on disk.
We can't tell "user deleted their last set" from "user never had sets and
another machine just added some", and silently deleting someone else's
data is the worse of the two failures.
"""
conflicts: list[str] = []
for key in BCC_OWNED_KEYS:
if key not in local_cfg:
continue
if key in fresh_cfg and fresh_cfg[key] != local_cfg[key]:
conflicts.append(key)
fresh_cfg[key] = copy.deepcopy(local_cfg[key])
return conflicts
def _make_backup(path: Path) -> Path: def _make_backup(path: Path) -> Path:
bdir = path.parent / BACKUP_DIRNAME bdir = path.parent / BACKUP_DIRNAME
bdir.mkdir(exist_ok=True) bdir.mkdir(exist_ok=True)
@@ -629,13 +893,28 @@ def backup_label(backup_path: Path | str) -> str:
return f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}:{ts[13:]}" return f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}:{ts[13:]}"
def should_mask_value(key: str, value) -> bool:
"""Whether an env/header value should be masked for display.
A ${VAR} reference is NOT a secret -- it's a pointer to one, and it's the
thing we want users to adopt. Masking it to dots would make a reference
indistinguishable from a stored credential, hiding exactly the distinction
that makes the feature worth using (#76).
"""
if not is_secret_key(key):
return False
return not is_env_ref(value) if isinstance(value, str) else True
def _redact_server_data(data: dict) -> dict: def _redact_server_data(data: dict) -> dict:
"""Return a copy of a server definition with secrets masked for display.""" """Return a copy of a server definition with secrets masked for display."""
out = dict(data) out = dict(data)
if "args" in out: if "args" in out:
out["args"] = redact_args(list(out["args"] or [])) out["args"] = redact_args(list(out["args"] or []))
if "env" in out: if "env" in out:
out["env"] = {k: (MASK if is_secret_key(k) else v) for k, v in (out["env"] or {}).items()} out["env"] = {
k: (MASK if should_mask_value(k, v) else v) for k, v in (out["env"] or {}).items()
}
return out return out
@@ -1186,6 +1465,155 @@ MASK = "••••••••"
_EMBEDDED_CRED_RE = re.compile(r"://[^:@/\s]+:[^:@/\s]+@") _EMBEDDED_CRED_RE = re.compile(r"://[^:@/\s]+:[^:@/\s]+@")
# --------------------------------------------------------------------------- #
# Environment-variable references (issue #76)
#
# Claude Code expands ${VAR} and ${VAR:-default} itself, in command, args, env,
# url and headers. So BCC does NOT expand these on write -- resolving them into
# the file would put the secret back on disk, which is the whole thing the user
# is avoiding, and would defeat a feature the client already implements. BCC
# authors, validates and warns.
#
# Claude Desktop has no documented support, so the same text there is passed to
# the server literally. That makes this a per-client capability, not a global
# one -- see client_expands_env_refs().
# --------------------------------------------------------------------------- #
# ${NAME} or ${NAME:-default}. Names follow the shell convention (letter or
# underscore first) so a bare "${}" or "${1}" isn't mistaken for a reference.
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
# The five fields Claude Code documents as expansion sites.
ENV_REF_FIELDS = ("command", "args", "env", "url", "headers")
class EnvRef(NamedTuple):
"""One ${VAR} / ${VAR:-default} occurrence found in a server definition."""
name: str
default: str | None
field: str # which of ENV_REF_FIELDS it was found in
@property
def has_default(self) -> bool:
return self.default is not None
def find_env_refs(text: str, field: str = "") -> list[EnvRef]:
"""Every ${VAR} / ${VAR:-default} reference in a single string."""
if not isinstance(text, str):
return []
return [EnvRef(m.group(1), m.group(2), field) for m in _ENV_REF_RE.finditer(text)]
def is_env_ref(value: str) -> bool:
"""True when the value contains at least one ${VAR} reference.
Used to keep placeholders OUT of secret masking: `${API_KEY}` under a
secret-looking key is a reference, not a secret, and masking it to dots
would hide the one distinction the user needs to see.
"""
return bool(find_env_refs(value))
def server_env_refs(data: dict) -> list[EnvRef]:
"""Every env reference in a server definition, tagged with its field.
Only inspects the fields Claude Code actually expands; a ${VAR} written
into some other key is not a reference and shouldn't be reported as one.
"""
out: list[EnvRef] = []
if not isinstance(data, dict):
return out
for field in ENV_REF_FIELDS:
value = data.get(field)
if isinstance(value, str):
out.extend(find_env_refs(value, field))
elif isinstance(value, list):
for item in value:
out.extend(find_env_refs(item, field))
elif isinstance(value, dict):
for v in value.values():
out.extend(find_env_refs(v, field))
return out
def expand_env_refs(text: str, environ: dict | None = None) -> str:
"""Expand ${VAR} / ${VAR:-default} the way Claude Code documents it.
Provided for previewing what the client will do -- BCC never writes the
expanded form back to the config. Unset with no default is left as the
literal ${VAR} text, matching Claude Code: the config still loads and the
unexpanded text is passed through.
"""
if not isinstance(text, str):
return text
env = os.environ if environ is None else environ
def repl(m: re.Match) -> str:
name, default = m.group(1), m.group(2)
if name in env:
return env[name]
return default if default is not None else m.group(0)
return _ENV_REF_RE.sub(repl, text)
def unresolved_env_refs(data: dict, environ: dict | None = None) -> list[EnvRef]:
"""References that would not resolve: variable unset AND no default.
Best-effort by nature -- BCC's environment isn't necessarily the client's,
so this warns rather than blocks, and the warning text says so.
"""
env = os.environ if environ is None else environ
return [r for r in server_env_refs(data) if not r.has_default and r.name not in env]
def client_expands_env_refs(profile: Profile) -> bool:
"""Whether the client behind `profile` expands ${VAR} itself.
Claude Code does, in command/args/env/url/headers, for both project
`.mcp.json` and user-scope `~/.claude.json`. Claude Desktop has no
documented support, so a reference there reaches the server as literal
text -- which surfaces as a confusing auth failure rather than an obvious
config error, hence the warning.
"""
return not profile_targets_claude_desktop(profile)
def env_ref_warnings(
data: dict, profile: Profile | None = None, environ: dict | None = None
) -> list[str]:
"""Advisory warnings about env references in one server definition.
Two distinct problems, deliberately worded differently:
- the target client won't expand them at all (Claude Desktop)
- the client will expand them, but a variable looks unset here
"""
refs = server_env_refs(data)
if not refs:
return []
if profile is not None and not client_expands_env_refs(profile):
names = ", ".join(sorted({f"${{{r.name}}}" for r in refs}))
return [
f"{names} will NOT be expanded by Claude Desktop -- it has no "
f"documented support for variable references, so the server "
f"receives the literal text. Use a real value here, or move this "
f"server to a Claude Code config."
]
missing = unresolved_env_refs(data, environ)
if not missing:
return []
names = ", ".join(sorted({r.name for r in missing}))
return [
f"{names} is not set in this environment and has no ':-default'. "
f"Claude Code will pass the reference through unexpanded. "
f"(Checked against BCC's environment, which may differ from the "
f"client's.)"
]
def is_secret_key(name: str) -> bool: def is_secret_key(name: str) -> bool:
"""Does this env-var / header / flag name look like it holds a secret?""" """Does this env-var / header / flag name look like it holds a secret?"""
return bool(_SECRET_KEY_RE.search(name or "")) return bool(_SECRET_KEY_RE.search(name or ""))
@@ -1202,17 +1630,22 @@ def redact_args(args: list[str]) -> list[str]:
--api-key=abc123 -> --api-key=•••••••• (inline flag=value) --api-key=abc123 -> --api-key=•••••••• (inline flag=value)
ghp_abc123 -> •••••••• (well-known token prefix) ghp_abc123 -> •••••••• (well-known token prefix)
Everything else passes through untouched. Everything else passes through untouched.
${VAR} references are left visible: they name a secret rather than being
one, and hiding them would obscure the difference between "this config
leaks a token" and "this config points at one" (#76).
""" """
out: list[str] = [] out: list[str] = []
mask_next = False mask_next = False
for a in args: for a in args:
s = str(a) s = str(a)
if mask_next: if mask_next:
out.append(MASK)
mask_next = False mask_next = False
out.append(s if is_env_ref(s) else MASK)
continue continue
if s.startswith("-") and "=" in s and is_secret_key(s.split("=", 1)[0]): if s.startswith("-") and "=" in s and is_secret_key(s.split("=", 1)[0]):
out.append(s.split("=", 1)[0] + "=" + MASK) flag, value = s.split("=", 1)
out.append(f"{flag}={value}" if is_env_ref(value) else f"{flag}={MASK}")
continue continue
if s.startswith("-") and is_secret_key(s): if s.startswith("-") and is_secret_key(s):
out.append(s) out.append(s)
@@ -1239,6 +1672,11 @@ def args_secret_warning(data: dict) -> str | None:
args = [str(a) for a in (data.get("args") or [])] args = [str(a) for a in (data.get("args") or [])]
mask_next = False mask_next = False
for a in args: for a in args:
# A ${VAR} reference is the recommended fix for this very warning --
# continuing to warn after the user adopts it punishes the fix (#76).
if is_env_ref(a):
mask_next = False
continue
if mask_next: if mask_next:
mask_next = False mask_next = False
if not a.startswith("-"): if not a.startswith("-"):
@@ -1380,9 +1818,21 @@ def lint_server(name: str, data: dict) -> list[str]:
def lint_servers(servers: list[ServerEntry]) -> list[str]: def lint_servers(servers: list[ServerEntry]) -> list[str]:
"""Concatenate lint_server warnings across every entry, in order.""" """Concatenate lint_server warnings across every entry, in order.
Entries whose config value wasn't a JSON object at all are reported here
rather than in lint_server, which takes an already-dict `data` (#72).
"""
out: list[str] = [] out: list[str] = []
for s in servers: for s in servers:
if s.malformed:
nm = s.name.strip() or "(unnamed)"
out.append(
f"'{nm}': server definition is not an object "
f"(found {type(s.raw).__name__}) -- it is preserved as-is; "
f"edit it to replace it with a proper definition"
)
continue
out.extend(lint_server(s.name, s.data)) out.extend(lint_server(s.name, s.data))
return out return out
@@ -2193,7 +2643,7 @@ CATALOG_DENIED_ENV_KEYS = frozenset(
# still trust an older key: verify_catalog_signature() accepts a match # still trust an older key: verify_catalog_signature() accepts a match
# against ANY key in this list. # against ANY key in this list.
CATALOG_PUBKEYS: list[bytes] = [ CATALOG_PUBKEYS: list[bytes] = [
base64.b64decode("0s24PmkZcTT5yxNDdyTPHl5fyxArrHNPJKBjnXoQd8k="), base64.b64decode("082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4="),
] ]
# Domain-separation prefix for the signed message. The signature covers # Domain-separation prefix for the signed message. The signature covers
+64 -553
View File
@@ -10,20 +10,14 @@ for data/catalog.json (issue #62).
Flow: Load -> Review -> Sign. Flow: Load -> Review -> Sign.
1. Load -- pick a source: an open Gitea PR touching data/catalog.json, 1. Load -- pick a source: an open Gitea PR touching data/catalog.json,
the current tip of `main`, or the branch this checkout is or the current tip of `main`. The Console fetches the exact
currently ON (or --ref names) -- the latter exists so a git blob (via a local clone's git plumbing) and PINS its
catalog-signing-key rotation, or any other catalog change blob SHA *and the ref it came from* for the rest of this
landed on a branch, can be reviewed and SIGNED before that review pass. For a PR, the diff is against `main`; for
branch is ever merged, instead of merging a PR that leaves `main`, the diff is against the last catalog a maintainer
main red and fixing it up afterwards (issue #68). The actually SIGNED (the bytes covered by the current
Console fetches the exact git blob (via a local clone's git data/catalog.json.sig), never against itself -- an empty
plumbing) and PINS its blob SHA *and the ref it came from* diff must mean "nothing to sign", never "sign unlocked".
for the rest of this review pass. For a PR, the diff is
against `main`; for `main` or a branch, the diff is against
the last catalog a maintainer actually SIGNED on that same
ref (the bytes covered by the current data/catalog.json.sig
there), never against itself -- an empty diff must mean
"nothing to sign", never "sign unlocked".
2. Review -- a semantic diff (catalog_review.diff_catalogs), one card per 2. Review -- a semantic diff (catalog_review.diff_catalogs), one card per
changed entry, with risk annotations changed entry, with risk annotations
(catalog_review.entry_risk_findings). A registry lookup for (catalog_review.entry_risk_findings). A registry lookup for
@@ -43,23 +37,13 @@ Flow: Load -> Review -> Sign.
blocking risk finding (catalog_review.sign_precondition / blocking risk finding (catalog_review.sign_precondition /
can_sign -- the TOCTOU fix). On success, writes can_sign -- the TOCTOU fix). On success, writes
data/catalog.json + data/catalog.json.sig and commits BOTH data/catalog.json + data/catalog.json.sig and commits BOTH
in a single commit, then pushes to the SAME ref that was in a single commit, then pushes -- so main is never red
reviewed (never a hardcoded "main") -- so that ref is never between a catalog merge and its signature. Signing uses the
red between a catalog merge and its signature, whether
that ref is `main` or a rotation branch. Signing uses the
CATALOG key ONLY -- see "Two signing keys" below. CATALOG key ONLY -- see "Two signing keys" below.
The signature must be the artefact of an actual review, not a step that The signature must be the artefact of an actual review, not a step that
follows one. Signing IS the approval act. follows one. Signing IS the approval act.
Confused about which key is which, or what state either is in? Run
`python catalog_console.py keys` -- it needs no passphrase and prints a
plain-English status report for both keys: where each private half lives,
whether it's present, its fingerprint, whether that fingerprint matches
what's committed in bcc_core.py / ci.yml / scripts/sign_checksums.py, and
whether data/catalog.json.sig currently verifies -- ending with exactly
what to run next.
Two signing keys (issue #68 finding 5): the CATALOG key (offline, Two signing keys (issue #68 finding 5): the CATALOG key (offline,
Console-only, `keygen` / `keygen --release` picks which) is the root of Console-only, `keygen` / `keygen --release` picks which) is the root of
trust for what BCC executes and must never touch CI. The RELEASE key is trust for what BCC executes and must never touch CI. The RELEASE key is
@@ -85,7 +69,6 @@ import urllib.error
import urllib.request import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import ClassVar
import bcc_core as core import bcc_core as core
import catalog_review as review import catalog_review as review
@@ -214,89 +197,6 @@ def unlock_signing_key(passphrase: str, kind: str = "catalog") -> bytes:
return review.decrypt_private_key(blob, passphrase) return review.decrypt_private_key(blob, passphrase)
def _pubkey_cache_file(kind: str) -> Path:
assert kind in _KEY_KINDS, f"unknown key kind {kind!r}, expected one of {_KEY_KINDS}"
return KEY_STORAGE_DIR / f"signing_key_{kind}.pub"
def store_public_key(pubkey: bytes, kind: str = "catalog") -> None:
"""Cache the PUBLIC half of a signing key, in plain base64, next to its
encrypted private counterpart (OS keychain if available, else the file
cache). Public keys are not secret -- this cache exists purely so
`catalog_console.py keys` can report a fingerprint and compare it
against what's committed in source WITHOUT ever decrypting (or asking
for a passphrase to unlock) the private key. Called by cmd_keygen()
right after a keypair is generated.
"""
keyring = _keyring_module()
pub_b64 = base64.b64encode(pubkey).decode("ascii")
if keyring is not None:
try:
keyring.set_password(_KEYRING_SERVICE, f"{_keyring_username(kind)}-pub", pub_b64)
return
except Exception:
pass # fall through to the file-based cache
KEY_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
_pubkey_cache_file(kind).write_text(pub_b64 + "\n", encoding="utf-8")
def load_public_key(kind: str = "catalog") -> bytes | None:
"""The cached PUBLIC key of the given `kind`, or None if no key of that
kind has been generated yet (or it predates public-key caching -- an
older keygen run that never called store_public_key). Never touches the
encrypted private blob and never asks for a passphrase."""
keyring = _keyring_module()
if keyring is not None:
try:
pub_b64 = keyring.get_password(_KEYRING_SERVICE, f"{_keyring_username(kind)}-pub")
if pub_b64:
return base64.b64decode(pub_b64)
except Exception:
pass
path = _pubkey_cache_file(kind)
if not path.exists():
return None
text = path.read_text(encoding="utf-8").strip()
if not text:
return None
try:
return base64.b64decode(text)
except ValueError:
return None
def has_encrypted_key(kind: str = "catalog") -> bool:
"""Whether a private key of this `kind` has been generated on this
machine -- checked WITHOUT decrypting or asking for a passphrase, so
`catalog_console.py keys` can report existence unconditionally."""
keyring = _keyring_module()
if keyring is not None:
try:
if keyring.get_password(_KEYRING_SERVICE, _keyring_username(kind)):
return True
except Exception:
pass
return _key_storage_file(kind).exists()
def describe_local_key_location(kind: str) -> str:
"""Human-readable description of where a `kind` key's PRIVATE half
lives on this machine, for the `keys` report and the sign-flow prompt.
Never the release key's CI location -- that's cmd_keys' job to append,
since it's true regardless of whether a local copy also exists."""
keyring = _keyring_module()
if keyring is not None:
try:
if keyring.get_password(_KEYRING_SERVICE, _keyring_username(kind)):
return "on this machine, in the OS keychain (encrypted, passphrase-protected)"
except Exception:
pass
key_file = _key_storage_file(kind)
if key_file.exists():
return f"on this machine, at {key_file} (encrypted, passphrase-protected)"
return "not generated yet"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# git plumbing against a local clone. The clone's `origin` remote is assumed # git plumbing against a local clone. The clone's `origin` remote is assumed
# to already carry credentials (the "tokened remote" every other BCC # to already carry credentials (the "tokened remote" every other BCC
@@ -323,40 +223,6 @@ def fetch_ref(repo_dir: Path, ref: str) -> str:
return _git(repo_dir, "rev-parse", "FETCH_HEAD").strip() return _git(repo_dir, "rev-parse", "FETCH_HEAD").strip()
def current_branch(repo_dir: Path) -> str | None:
"""The branch currently checked out at `repo_dir`, or None if it can't
be determined (detached HEAD, bare repo, mid-rebase, ...). This is what
lets the Console offer "sign against the branch I already have checked
out" as a source (issue #68 rotation-completability fix) without the
maintainer having to type the branch name -- ReviewWindow defaults to
it unless --ref names one explicitly.
"""
try:
name = _git(repo_dir, "rev-parse", "--abbrev-ref", "HEAD").strip()
except GitError:
return None
return None if name in ("", "HEAD") else name
def compute_own_refs(explicit_ref: str | None, detected_branch: str | None) -> list[str]:
""" "main" plus (if different) `explicit_ref` or `detected_branch` -- the
exact list of refs ReviewWindow offers as "own" sources (loadable,
signable, AND pushable directly, unlike a PR's read-only
refs/pull/<n>/head). Pure: `detected_branch` is injected (normally
current_branch(repo_dir)) so this ref-resolution seam -- the fix for
issue #68's "rotation can't complete without a red main" -- is testable
without git or Qt. "main" is always index 0 so a stale/absent
list-widget selection still defaults sanely (see ReviewWindow._on_load's
row-clamping, and cmd_gui/ReviewWindow.__init__ for how `explicit_ref`
is threaded from --ref).
"""
refs = ["main"]
branch = explicit_ref or detected_branch
if branch and branch not in refs:
refs.append(branch)
return refs
def blob_sha_at(repo_dir: Path, commit: str, path: str) -> str: def blob_sha_at(repo_dir: Path, commit: str, path: str) -> str:
"""The git blob SHA of `path` as it exists at `commit`. This is what """The git blob SHA of `path` as it exists at `commit`. This is what
gets pinned at review-start and re-checked immediately before signing gets pinned at review-start and re-checked immediately before signing
@@ -417,48 +283,17 @@ def last_signed_catalog_raw(repo_dir: Path, commit: str) -> bytes | None:
return review.find_last_signed_catalog_raw(candidates, sig_bytes, core.CATALOG_PUBKEYS) return review.find_last_signed_catalog_raw(candidates, sig_bytes, core.CATALOG_PUBKEYS)
def catalog_signature_valid_at(repo_dir: Path, commit: str, raw: bytes) -> bool:
"""Whether data/catalog.json.sig as of `commit` verifies `raw` against
the CURRENTLY-TRUSTED bcc_core.CATALOG_PUBKEYS.
False whenever there is no .sig file, or the .sig exists but was
produced by a key that is not (or no longer) in CATALOG_PUBKEYS -- most
notably right after a catalog signing-key rotation (issue #68 finding 5
follow-up), when the committed .sig was produced by the now-retired old
key. This is the rotation-detection primitive ReviewWindow._on_load
uses to decide whether to enter re-attestation mode
(review.start_review(..., reattest=True)) instead of the ordinary
diff-against-last-signed path.
Delegates to bcc_core.verify_catalog_signature -- never reimplemented,
per this module's docstring ("one source of truth").
"""
try:
sig_sha = blob_sha_at(repo_dir, commit, SIG_PATH)
except GitError:
return False
sig_bytes = blob_bytes(repo_dir, sig_sha)
return core.verify_catalog_signature(raw, sig_bytes, core.CATALOG_PUBKEYS)
def commit_and_push_signed_catalog( def commit_and_push_signed_catalog(
repo_dir: Path, raw_bytes: bytes, signature: bytes, *, branch: str = "main" repo_dir: Path, raw_bytes: bytes, signature: bytes, *, branch: str = "main"
) -> str: ) -> str:
"""Write data/catalog.json + data/catalog.json.sig and commit BOTH in a """Write data/catalog.json + data/catalog.json.sig and commit BOTH in a
single commit, then push to `branch`. Returns the new commit SHA. single commit, then push to `branch`. Returns the new commit SHA.
`branch` MUST be the same ref that was actually reviewed
(ReviewWindow._on_sign passes `session.loaded_ref`, never a hardcoded
"main" -- issue #68 completability fix): a catalog change reviewed on a
branch has to be signed and pushed to THAT branch so the branch itself
is never red, rather than landing the signature on main after a merge.
This is deliberate: if signing happened in a commit AFTER the catalog This is deliberate: if signing happened in a commit AFTER the catalog
merge, `branch` would be red (payload present, signature missing) merge, main would be red (payload present, signature missing) between
between the catalog merge and its signing commit. Routine red branches every catalog merge and its signing commit. Routine red-main trains
train exactly the alarm fatigue this whole design exists to prevent. exactly the alarm fatigue this whole design exists to prevent. Emitting
Emitting one commit with both files means `branch` is never in that one commit with both files means main is never in that state.
state -- whether `branch` is main or a rotation-in-progress branch.
""" """
_git(repo_dir, "checkout", branch) _git(repo_dir, "checkout", branch)
_git(repo_dir, "pull", "--ff-only", "origin", branch) _git(repo_dir, "pull", "--ff-only", "origin", branch)
@@ -648,76 +483,14 @@ def registry_fetcher(ref: review.PackageRef) -> dict | None:
return None return None
# --------------------------------------------------------------------------- #
# Key status ("keys" command, issue #62/#68 follow-up: "make key handling
# comprehensible"). File I/O only -- the actual status/report logic is pure
# and lives in catalog_review (key_status / render_key_status_report), so
# it's unit-testable without touching disk. These wrappers read the THREE
# places a signing key's public half is expected to be committed, from
# `repo_dir`'s working tree, so `keys` reports on whatever ref/branch is
# actually checked out there (never this process's own sys.path import).
# --------------------------------------------------------------------------- #
def read_catalog_pubkeys_from_source(repo_dir: Path) -> list[bytes]:
path = repo_dir / "bcc_core.py"
if not path.exists():
return []
return review.extract_pubkey_list_literal(path.read_text(encoding="utf-8"), "CATALOG_PUBKEYS")
def read_release_pubkeys_from_source(repo_dir: Path) -> list[bytes]:
path = repo_dir / "scripts" / "sign_checksums.py"
if not path.exists():
return []
return review.extract_pubkey_list_literal(path.read_text(encoding="utf-8"), "RELEASE_PUBKEYS")
def read_ci_trust_anchor_pubkeys(repo_dir: Path) -> list[bytes]:
path = repo_dir / ".github" / "workflows" / "ci.yml"
if not path.exists():
return []
pubkey = review.extract_ci_trust_anchor_pubkey(path.read_text(encoding="utf-8"))
return [pubkey] if pubkey is not None else []
def catalog_sig_status_on_disk(repo_dir: Path, committed_catalog_pubkeys: list[bytes]) -> str:
""" "valid" / "invalid" / "missing" for data/catalog.json.sig as it sits
in `repo_dir`'s WORKING TREE right now (not a git ref -- the `keys`
command deliberately reports on-disk state, which is what "the current
checked-out branch" concretely means and avoids a network fetch just to
print a status line). Verified against `committed_catalog_pubkeys`
(whatever bcc_core.py on this same working tree currently says), not
the running process's own bcc_core import, so this stays correct no
matter which ref/branch happens to be checked out.
"""
catalog_path = repo_dir / CATALOG_PATH
sig_path = repo_dir / SIG_PATH
if not catalog_path.exists() or not sig_path.exists():
return "missing"
raw = catalog_path.read_bytes()
sig = sig_path.read_bytes()
if core.verify_catalog_signature(raw, sig, committed_catalog_pubkeys):
return "valid"
return "invalid"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# GUI (PySide6). Everything above this line has no Qt dependency and is # GUI (PySide6). Everything above this line has no Qt dependency and is
# exercised by tests/test_catalog_review.py; everything below is a thin # exercised by tests/test_catalog_review.py; everything below is a thin
# shell that calls into it. # shell that calls into it.
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# PySide6 is imported defensively: `keygen`, `show-seed-b64`, and `keys` from PySide6.QtCore import Qt, QThread, Signal # noqa: E402
# (the commands a maintainer runs most often, and the ones this file's from PySide6.QtWidgets import ( # noqa: E402
# tests/py_compile-only CI environment can exercise) have no GUI dependency
# at all and must keep working even somewhere PySide6 isn't installed or
# won't import (e.g. no system Qt libs). Only `gui` needs it -- cmd_gui()
# checks _PYSIDE6_AVAILABLE and fails with a clear message instead of an
# ImportError stack trace if it's missing.
try:
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtWidgets import (
QApplication, QApplication,
QCheckBox, QCheckBox,
QDialog, QDialog,
@@ -736,15 +509,7 @@ try:
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
except ImportError as _pyside6_exc: # pragma: no cover - only hit where PySide6 is absent
_PYSIDE6_IMPORT_ERROR: str | None = str(_pyside6_exc)
else:
_PYSIDE6_IMPORT_ERROR = None
_PYSIDE6_AVAILABLE = _PYSIDE6_IMPORT_ERROR is None
if _PYSIDE6_AVAILABLE: # pragma: no branch - GUI class defs, only skipped where PySide6 is absent
def plain_label(text: object) -> QLabel: def plain_label(text: object) -> QLabel:
"""A QLabel guaranteed to render `text` as plain text, never HTML. """A QLabel guaranteed to render `text` as plain text, never HTML.
@@ -762,8 +527,10 @@ if _PYSIDE6_AVAILABLE: # pragma: no branch - GUI class defs, only skipped where
label.setWordWrap(True) label.setWordWrap(True)
return label return label
_SEVERITY_PREFIX = {"blocking": "✖ BLOCKING", "warning": "⚠ WARNING", "info": " INFO"} _SEVERITY_PREFIX = {"blocking": "✖ BLOCKING", "warning": "⚠ WARNING", "info": " INFO"}
class RegistryLookupWorker(QThread): class RegistryLookupWorker(QThread):
"""Off-UI-thread registry lookups, mirroring bcc.py's ConnTester/ """Off-UI-thread registry lookups, mirroring bcc.py's ConnTester/
SpawnTester pattern. Never blocks the review UI on a slow/dead network.""" SpawnTester pattern. Never blocks the review UI on a slow/dead network."""
@@ -782,6 +549,7 @@ if _PYSIDE6_AVAILABLE: # pragma: no branch - GUI class defs, only skipped where
] ]
self.done.emit(results) self.done.emit(results)
class EntryCard(QWidget): class EntryCard(QWidget):
"""One changed catalog entry: the diff, risk findings, and the """One changed catalog entry: the diff, risk findings, and the
acknowledge checkbox that gates Sign. `command`/`args` are rendered acknowledge checkbox that gates Sign. `command`/`args` are rendered
@@ -893,24 +661,15 @@ if _PYSIDE6_AVAILABLE: # pragma: no branch - GUI class defs, only skipped where
self.registry_label.setText(html.escape(text)) self.registry_label.setText(html.escape(text))
self.registry_label.setTextFormat(Qt.PlainText) self.registry_label.setTextFormat(Qt.PlainText)
class PassphraseDialog(QDialog): class PassphraseDialog(QDialog):
"""Prompts for a signing key's passphrase. def __init__(self, prompt: str, parent=None):
`prompt` must say plainly WHICH key (CATALOG or RELEASE) is about to be
unlocked, and its fingerprint when known -- issue #62/#68 follow-up: a
maintainer must see which key he's about to type a passphrase for
BEFORE typing it, not infer it from context. This is the exact ambiguity
that led to a private key being pasted into a chat window.
"""
def __init__(self, prompt: str, window_title: str = "Signing key passphrase", parent=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(window_title) self.setWindowTitle("Signing key passphrase")
layout = QFormLayout(self) layout = QFormLayout(self)
layout.addRow(plain_label(prompt))
self.edit = QLineEdit() self.edit = QLineEdit()
self.edit.setEchoMode(QLineEdit.EchoMode.Password) self.edit.setEchoMode(QLineEdit.EchoMode.Password)
layout.addRow("Passphrase:", self.edit) layout.addRow(prompt, self.edit)
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
) )
@@ -921,26 +680,14 @@ if _PYSIDE6_AVAILABLE: # pragma: no branch - GUI class defs, only skipped where
def passphrase(self) -> str: def passphrase(self) -> str:
return self.edit.text() return self.edit.text()
class ReviewWindow(QMainWindow):
#: Placeholder empty catalog used when there is nothing to diff against
#: yet (no prior signature, or a key rotation invalidated the old one).
_EMPTY_CATALOG: ClassVar[dict] = {"schema": 1, "version": 0, "servers": []}
def __init__(self, repo_dir: Path, ref: str | None = None): class ReviewWindow(QMainWindow):
def __init__(self, repo_dir: Path):
super().__init__() super().__init__()
self.repo_dir = repo_dir self.repo_dir = repo_dir
self.session: review.ReviewSession | None = None self.session: review.ReviewSession | None = None
self.cards: dict[str, EntryCard] = {} self.cards: dict[str, EntryCard] = {}
# "Own" refs this clone can load/diff/sign+push against directly --
# issue #68 rotation-completability fix. "main" is always offered;
# if the checkout is on a different branch (or --ref names one
# explicitly), that branch is offered too, so a rotation in
# progress on a branch can be reviewed, signed, and pushed to ITS
# OWN ref -- completing the rotation before merge, never forcing a
# red main in between. See _load_own_ref() / _on_sign().
self._own_refs = self._compute_own_refs(ref)
self.setWindowTitle("BCC Catalog Console -- maintainer-only, never shipped") self.setWindowTitle("BCC Catalog Console -- maintainer-only, never shipped")
central = QWidget() central = QWidget()
self.setCentralWidget(central) self.setCentralWidget(central)
@@ -948,13 +695,7 @@ if _PYSIDE6_AVAILABLE: # pragma: no branch - GUI class defs, only skipped where
top = QHBoxLayout() top = QHBoxLayout()
self.source_list = QListWidget() self.source_list = QListWidget()
for own_ref in self._own_refs: self.source_list.addItem(QListWidgetItem("main (current tip)"))
label = (
f"{own_ref} (current tip)"
if own_ref == "main"
else f"{own_ref} (current branch)"
)
self.source_list.addItem(QListWidgetItem(label))
top.addWidget(self.source_list, 1) top.addWidget(self.source_list, 1)
side = QVBoxLayout() side = QVBoxLayout()
@@ -968,22 +709,6 @@ if _PYSIDE6_AVAILABLE: # pragma: no branch - GUI class defs, only skipped where
top.addLayout(side) top.addLayout(side)
root.addLayout(top) root.addLayout(top)
# KEY-ROTATION RE-ATTESTATION BANNER (issue #68 finding 5 follow-up).
# Hidden until _on_load() detects that the loaded catalog's
# signature does NOT verify under the currently-trusted
# bcc_core.CATALOG_PUBKEYS (catalog_signature_valid_at()) -- most
# commonly, right after the maintainer rotates the catalog signing
# key. This mode must NEVER be entered silently: this banner is the
# only thing standing between "every entry needs re-review" and a
# maintainer wondering why the diff view suddenly shows 19
# "added" entries with no explanation.
self.reattest_banner = plain_label("")
self.reattest_banner.setStyleSheet(
"background-color: #c62828; color: white; font-weight: bold; padding: 8px;"
)
self.reattest_banner.setVisible(False)
root.addWidget(self.reattest_banner)
self.scroll = QScrollArea() self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True) self.scroll.setWidgetResizable(True)
self.card_container = QWidget() self.card_container = QWidget()
@@ -1003,66 +728,41 @@ if _PYSIDE6_AVAILABLE: # pragma: no branch - GUI class defs, only skipped where
self._prs: list[CatalogPR] = [] self._prs: list[CatalogPR] = []
self._refresh_pr_list() self._refresh_pr_list()
def _compute_own_refs(self, explicit_ref: str | None) -> list[str]:
"""Thin GUI-side wrapper: injects current_branch(self.repo_dir)
(a git call) into the pure compute_own_refs() -- see that
function's docstring for what this list actually means."""
return compute_own_refs(explicit_ref, current_branch(self.repo_dir))
def _refresh_pr_list(self): def _refresh_pr_list(self):
self._prs = list_open_catalog_prs(self._token) self._prs = list_open_catalog_prs(self._token)
while self.source_list.count() > len(self._own_refs): while self.source_list.count() > 1:
self.source_list.takeItem(len(self._own_refs)) self.source_list.takeItem(1)
for pr in self._prs: for pr in self._prs:
self.source_list.addItem(QListWidgetItem(f"PR #{pr.number}: {pr.title}")) self.source_list.addItem(QListWidgetItem(f"PR #{pr.number}: {pr.title}"))
def _load_own_ref(self, loaded_ref: str): def _on_load(self):
"""Load + diff `loaded_ref` -- "main" or the maintainer's own row = self.source_list.currentRow()
working branch, ANY ref this clone's origin can fetch directly (as try:
opposed to a PR's read-only refs/pull/<n>/head). Diffs against the if row <= 0:
last catalog a maintainer actually SIGNED on that ref (never # source = main: diff against the last catalog a maintainer
against itself -- an empty diff must mean "nothing to sign", never # actually SIGNED (the bytes covered by the current
"sign unlocked", issue #68 finding 1) and enters KEY-ROTATION # data/catalog.json.sig), never against itself. Diffing
RE-ATTESTATION mode if the committed .sig doesn't verify under the # main-vs-main is what made an empty diff -> instantly
currently-trusted bcc_core.CATALOG_PUBKEYS (issue #68 finding 5 # "signable" in the first place (issue #68 finding 1) --
follow-up). This is the exact logic "main" always used -- pulled # this is the only path that reaches sign_catalog_bytes(),
into its own method, parameterized on the ref, so a rotation on a # so if it can't be trusted nothing can.
branch gets the SAME treatment as one on main and can be signed loaded_ref = "main"
(and pushed back to that SAME branch, see _on_sign) before merge --
the completability fix this method exists for.
Returns (new_raw, new_blob_sha, new_catalog, old_catalog, reattest).
"""
commit = fetch_ref(self.repo_dir, loaded_ref) commit = fetch_ref(self.repo_dir, loaded_ref)
new_raw, new_blob_sha = read_catalog_at_commit(self.repo_dir, commit) new_raw, new_blob_sha = read_catalog_at_commit(self.repo_dir, commit)
new_catalog = core.load_catalog(new_raw) new_catalog = core.load_catalog(new_raw)
if not catalog_signature_valid_at(self.repo_dir, commit, new_raw):
reattest = True
old_catalog = dict(self._EMPTY_CATALOG)
else:
reattest = False
old_raw = last_signed_catalog_raw(self.repo_dir, commit) old_raw = last_signed_catalog_raw(self.repo_dir, commit)
old_catalog = ( old_catalog = (
core.load_catalog(old_raw) if old_raw is not None else dict(self._EMPTY_CATALOG) core.load_catalog(old_raw)
) if old_raw is not None
else {
return new_raw, new_blob_sha, new_catalog, old_catalog, reattest "schema": 1,
"version": 0,
def _on_load(self): "servers": [],
row = self.source_list.currentRow() }
if row < 0:
row = 0 # nothing explicitly selected -- default to index 0 ("main")
try:
if row < len(self._own_refs):
loaded_ref = self._own_refs[row]
new_raw, new_blob_sha, new_catalog, old_catalog, reattest = self._load_own_ref(
loaded_ref
) )
else: else:
pr = self._prs[row - len(self._own_refs)] pr = self._prs[row - 1]
loaded_ref = pr.head_ref loaded_ref = pr.head_ref
reattest = False
commit = fetch_ref(self.repo_dir, loaded_ref) commit = fetch_ref(self.repo_dir, loaded_ref)
old_commit = fetch_ref(self.repo_dir, "main") old_commit = fetch_ref(self.repo_dir, "main")
@@ -1081,21 +781,8 @@ if _PYSIDE6_AVAILABLE: # pragma: no branch - GUI class defs, only skipped where
# from the SAME ref that was reviewed, instead of a hardcoded # from the SAME ref that was reviewed, instead of a hardcoded
# "main" -- see review.sign_precondition and issue #68 finding 1. # "main" -- see review.sign_precondition and issue #68 finding 1.
self.session = review.start_review( self.session = review.start_review(
new_blob_sha, old_catalog, new_catalog, loaded_ref=loaded_ref, reattest=reattest new_blob_sha, old_catalog, new_catalog, loaded_ref=loaded_ref
) )
if reattest:
self.reattest_banner.setText(
"KEY ROTATION IN PROGRESS -- the trusted CATALOG signing key changed. "
"The existing data/catalog.json.sig does NOT verify under the current "
"bcc_core.CATALOG_PUBKEYS, so it is NOT trusted. Every one of the "
f"{len(self.session.changes)} entries below must be re-reviewed and "
"acknowledged before the new CATALOG key can re-sign this catalog -- "
"this is the intended cost of rotating the CATALOG key, not a bug."
)
self.reattest_banner.setVisible(True)
else:
self.reattest_banner.setVisible(False)
self.reattest_banner.setText("")
self._render_cards() self._render_cards()
def _render_cards(self): def _render_cards(self):
@@ -1107,11 +794,7 @@ if _PYSIDE6_AVAILABLE: # pragma: no branch - GUI class defs, only skipped where
assert self.session is not None assert self.session is not None
all_ids = sorted( all_ids = sorted(
{ {e.get("id") for e in (self.session.new_catalog.get("servers") or []) if e.get("id")}
e.get("id")
for e in (self.session.new_catalog.get("servers") or [])
if e.get("id")
}
) )
for change in self.session.changes: for change in self.session.changes:
card = EntryCard(change, all_ids) card = EntryCard(change, all_ids)
@@ -1134,12 +817,6 @@ if _PYSIDE6_AVAILABLE: # pragma: no branch - GUI class defs, only skipped where
all_ack = review.all_entries_acknowledged(self.session) all_ack = review.all_entries_acknowledged(self.session)
self.sign_btn.setEnabled(all_ack) self.sign_btn.setEnabled(all_ack)
pending = len(self.session.changes) - len(self.session.acknowledged) pending = len(self.session.changes) - len(self.session.acknowledged)
if self.session.reattest:
self.status_label.setText(
f"RE-ATTESTATION under the new key: {len(self.session.changes)} entries, "
f"{pending} not yet acknowledged."
)
else:
self.status_label.setText( self.status_label.setText(
f"{len(self.session.changes)} changed entries, {pending} not yet acknowledged." f"{len(self.session.changes)} changed entries, {pending} not yet acknowledged."
) )
@@ -1182,52 +859,23 @@ if _PYSIDE6_AVAILABLE: # pragma: no branch - GUI class defs, only skipped where
self._on_load() self._on_load()
return return
# Show WHICH key is about to be used, and its fingerprint, BEFORE dialog = PassphraseDialog("Enter CATALOG signing key passphrase:", self)
# the passphrase field even appears -- issue #62/#68 follow-up. A
# maintainer must never have to infer which key a bare "Enter
# passphrase" prompt means; that ambiguity is exactly what led to a
# private key being pasted into a chat window.
catalog_pubkey = load_public_key("catalog")
if catalog_pubkey is not None:
fp_note = f"fingerprint {review.fingerprint_pubkey(catalog_pubkey)}"
else:
fp_note = "fingerprint unknown (generated before fingerprint caching -- re-run keygen to cache it)"
prompt = (
f"About to sign with the CATALOG signing key ({fp_note}).\n"
"This is the root of trust for what BCC executes on a user's machine -- "
"it is never the RELEASE key.\n\n"
"Enter the CATALOG key's passphrase:"
)
dialog = PassphraseDialog(prompt, "CATALOG signing key passphrase", self)
if dialog.exec() != QDialog.DialogCode.Accepted: if dialog.exec() != QDialog.DialogCode.Accepted:
return return
try: try:
seed = unlock_signing_key(dialog.passphrase(), kind="catalog") seed = unlock_signing_key(dialog.passphrase())
except (FileNotFoundError, ValueError) as e: except (FileNotFoundError, ValueError) as e:
QMessageBox.critical(self, "Sign failed", html.escape(str(e))) QMessageBox.critical(self, "Sign failed", html.escape(str(e)))
return return
signature = review.sign_catalog_bytes(self._new_raw, seed) signature = review.sign_catalog_bytes(self._new_raw, seed)
try: try:
# Push to the SAME ref that was reviewed (session.loaded_ref), new_commit = commit_and_push_signed_catalog(self.repo_dir, self._new_raw, signature)
# never a hardcoded "main" -- issue #68 completability fix. A
# rotation (or any other catalog change) reviewed on a branch
# must land on THAT branch so it can be signed and pushed
# before the branch is ever merged, instead of forcing a merge
# of a red PR followed by a fix-up on main.
new_commit = commit_and_push_signed_catalog(
self.repo_dir, self._new_raw, signature, branch=self.session.loaded_ref
)
except GitError as e: except GitError as e:
QMessageBox.critical(self, "Commit/push failed", html.escape(str(e))) QMessageBox.critical(self, "Commit/push failed", html.escape(str(e)))
return return
QMessageBox.information( QMessageBox.information(self, "Signed", f"Signed and pushed as commit {new_commit[:12]}.")
self,
"Signed",
f"Signed with the CATALOG key and pushed to {self.session.loaded_ref} "
f"as commit {new_commit[:12]}.",
)
self.sign_btn.setEnabled(False) self.sign_btn.setEnabled(False)
@@ -1257,10 +905,9 @@ def cmd_keygen(args: argparse.Namespace) -> int:
blob = review.encrypt_private_key(seed, passphrase) blob = review.encrypt_private_key(seed, passphrase)
where = store_encrypted_key(blob, kind=kind) where = store_encrypted_key(blob, kind=kind)
store_public_key(pubkey, kind=kind) # non-secret; lets `keys` fingerprint without a passphrase
pubkey_b64 = base64.b64encode(pubkey).decode("ascii") pubkey_b64 = base64.b64encode(pubkey).decode("ascii")
print(f"{kind.upper()} private key encrypted and stored in: {where}") print(f"{kind.capitalize()} private key encrypted and stored in: {where}")
print() print()
if kind == "catalog": if kind == "catalog":
print( print(
@@ -1273,12 +920,8 @@ def cmd_keygen(args: argparse.Namespace) -> int:
) )
print() print()
print( print(
"PUBLIC key (base64, safe to commit/share) -- hand this to whoever " "Public key (base64) -- hand this to whoever maintains bcc_core.py so "
"maintains bcc_core.py so they can add it to CATALOG_PUBKEYS (this tool " "they can add it to CATALOG_PUBKEYS (this tool does not edit that file):"
"does not edit that file). There is no PRIVATE-key output for the catalog "
"key: it is never meant to leave this machine, and this tool has no "
"command that exports it (show-seed-b64 refuses without --release, and "
"even then only exports the release key)."
) )
print(f" {pubkey_b64}") print(f" {pubkey_b64}")
else: else:
@@ -1289,23 +932,14 @@ def cmd_keygen(args: argparse.Namespace) -> int:
"contain it)." "contain it)."
) )
print() print()
print( print("1. Public key (base64) -- paste into scripts/sign_checksums.py RELEASE_PUBKEYS:")
"1. PUBLIC key (base64, safe to commit/share) -- paste into "
"scripts/sign_checksums.py RELEASE_PUBKEYS:"
)
print(f" {pubkey_b64}") print(f" {pubkey_b64}")
print() print()
print( print(
"2. PRIVATE key (secret -- NEVER commit, NEVER paste into chat/email) -- " "2. Private key -- add it as the Gitea repo secret RELEASE_SIGNING_KEY "
"add it as the Gitea repo secret RELEASE_SIGNING_KEY (base64 of the " "(base64 of the 32-byte private seed). Get that value with:"
"32-byte private seed). This command does not print it; fetch it "
"separately, when you're ready to paste it straight into the Gitea "
"secret field, with:"
)
print(
" python catalog_console.py show-seed-b64 --release "
"# prints the PRIVATE key -- read the warning banner it shows"
) )
print(" python catalog_console.py show-seed-b64 --release # prints the raw key")
return 0 return 0
@@ -1325,49 +959,17 @@ def cmd_show_seed_b64(args: argparse.Namespace) -> int:
file=sys.stderr, file=sys.stderr,
) )
return 1 return 1
# The prior wording ("Release signing key passphrase:") was ambiguous passphrase = getpass.getpass("Release signing key passphrase: ")
# enough that a maintainer who ran this command, saw that prompt, and
# then saw a base64 blob printed with zero surrounding context, pasted
# the output into a chat believing it was the PUBLIC key. It is not --
# it is the raw private seed. Every string this command prints from here
# down exists to make that mistake structurally harder to make again.
passphrase = getpass.getpass(
"Passphrase for the release signing key (the one YOU chose when generating it): "
)
try: try:
seed = unlock_signing_key(passphrase, kind="release") seed = unlock_signing_key(passphrase, kind="release")
except (FileNotFoundError, ValueError) as e: except (FileNotFoundError, ValueError) as e:
print(f"error: {e}", file=sys.stderr) print(f"error: {e}", file=sys.stderr)
return 1 return 1
# Loud banner on STDERR, seed alone on STDOUT -- so `show-seed-b64
# --release | pbcopy` (or piping into the Gitea secret field) still
# gets ONLY the seed, while anyone watching the terminal still sees the
# warning. Never merge these into one stream.
print("!!! PRIVATE KEY BELOW -- this is the RELEASE_SIGNING_KEY secret value.", file=sys.stderr)
print(
"!!! Paste it ONLY into the Gitea secret field. Never into chat, email, "
"a file, or a commit.",
file=sys.stderr,
)
print(
"!!! Anyone holding this value can forge release checksum signatures.",
file=sys.stderr,
)
print(base64.b64encode(seed).decode("ascii")) print(base64.b64encode(seed).decode("ascii"))
return 0 return 0
def cmd_gui(args: argparse.Namespace) -> int: def cmd_gui(args: argparse.Namespace) -> int:
if not _PYSIDE6_AVAILABLE:
print(
"error: PySide6 is not available in this Python environment, so the GUI "
f"can't launch ({_PYSIDE6_IMPORT_ERROR}). `keygen`, `show-seed-b64`, and "
"`keys` don't need it and still work here.",
file=sys.stderr,
)
return 1
repo_dir = Path(args.repo).resolve() repo_dir = Path(args.repo).resolve()
if not (repo_dir / CATALOG_PATH).exists(): if not (repo_dir / CATALOG_PATH).exists():
print( print(
@@ -1378,98 +980,18 @@ def cmd_gui(args: argparse.Namespace) -> int:
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("BCC Catalog Console") app.setApplicationName("BCC Catalog Console")
win = ReviewWindow(repo_dir, ref=args.ref) win = ReviewWindow(repo_dir)
win.resize(900, 700) win.resize(900, 700)
win.show() win.show()
return app.exec() return app.exec()
def cmd_keys(args: argparse.Namespace) -> int:
"""`python catalog_console.py keys` -- "which key is what, and what
state is everything in?" (issue #62/#68 follow-up). Reads the CURRENT
WORKING TREE at --repo (so it reports on whatever branch/ref is
actually checked out there -- issue #68 rotation-completability fix
means that's often not "main" during a rotation), gathers every input
the pure catalog_review.key_status()/render_key_status_report() need,
and prints the result. Never touches, decrypts, or prints a private key
-- everything gathered here is a cached PUBLIC key, a fingerprint, file
text, or a signature verification result.
"""
repo_dir = Path(args.repo).resolve()
branch = current_branch(repo_dir) or "(unknown -- detached HEAD or not a git checkout)"
print(f"Catalog Console -- key status for {repo_dir}")
print(f"Checked-out ref: {branch}")
print()
committed_catalog_pubkeys = read_catalog_pubkeys_from_source(repo_dir)
ci_trust_anchor_pubkeys = read_ci_trust_anchor_pubkeys(repo_dir)
committed_release_pubkeys = read_release_pubkeys_from_source(repo_dir)
catalog_status = review.key_status(
"catalog",
display_name="CATALOG",
purpose=(
"Signs the server list that BCC writes into your Claude config. This is "
"what decides which programs run on a user's machine -- the root of trust."
),
private_key_location=describe_local_key_location("catalog"),
local_exists=has_encrypted_key("catalog"),
local_pubkey=load_public_key("catalog"),
locations=[
("bcc_core.CATALOG_PUBKEYS", committed_catalog_pubkeys),
("ci.yml trust anchor (EXPECTED_CATALOG_PUBKEY_B64)", ci_trust_anchor_pubkeys),
],
catalog_sig_status=catalog_sig_status_on_disk(repo_dir, committed_catalog_pubkeys),
)
release_where = describe_local_key_location("release")
if release_where == "not generated yet":
release_where = (
"not generated yet, and not verifiable from here as present in CI "
"(check the Gitea repo secret RELEASE_SIGNING_KEY directly)"
)
else:
release_where = (
f"{release_where} -- intended to be pasted into the Gitea secret "
"RELEASE_SIGNING_KEY (via `show-seed-b64 --release`) and not kept as the "
"primary copy once that's done"
)
release_status = review.key_status(
"release",
display_name="RELEASE",
purpose=(
"Signs the SHA256SUMS checksum manifest for release downloads only. "
"CI-resident on purpose: a CI compromise burns this key, never the "
"catalog key -- that asymmetry is the whole point of having two keys."
),
private_key_location=release_where,
local_exists=has_encrypted_key("release"),
local_pubkey=load_public_key("release"),
locations=[("scripts/sign_checksums.py RELEASE_PUBKEYS", committed_release_pubkeys)],
catalog_sig_status=None,
)
print(review.render_key_status_report([catalog_status, release_status]))
return 0
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command") sub = parser.add_subparsers(dest="command")
p_gui = sub.add_parser("gui", help="launch the review/sign GUI (default)") p_gui = sub.add_parser("gui", help="launch the review/sign GUI (default)")
p_gui.add_argument("--repo", default=".", help="path to a BCC git checkout (default: cwd)") p_gui.add_argument("--repo", default=".", help="path to a BCC git checkout (default: cwd)")
p_gui.add_argument(
"--ref",
default=None,
help=(
"branch to offer as an additional load/sign/push source, alongside "
"'main' and any open PRs -- e.g. a key-rotation branch, so rotation "
"can be reviewed and signed BEFORE merge instead of forcing a red main "
"(issue #68). Defaults to whatever branch --repo is currently checked "
"out on; only needed if that's not the branch you mean."
),
)
p_gui.set_defaults(func=cmd_gui) p_gui.set_defaults(func=cmd_gui)
p_keygen = sub.add_parser( p_keygen = sub.add_parser(
@@ -1497,21 +1019,10 @@ def build_parser() -> argparse.ArgumentParser:
) )
p_seed.set_defaults(func=cmd_show_seed_b64) p_seed.set_defaults(func=cmd_show_seed_b64)
p_keys = sub.add_parser(
"keys",
help=(
"print a plain-English status report: which key is what, where its "
"private half lives, whether it matches what's committed, and whether "
"data/catalog.json.sig currently verifies"
),
)
p_keys.add_argument("--repo", default=".", help="path to a BCC git checkout (default: cwd)")
p_keys.set_defaults(func=cmd_keys)
return parser return parser
_SUBCOMMANDS = ("gui", "keygen", "show-seed-b64", "keys", "-h", "--help") _SUBCOMMANDS = ("gui", "keygen", "show-seed-b64", "-h", "--help")
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
-277
View File
@@ -16,8 +16,6 @@ new surface" recurring-bug lesson).
from __future__ import annotations from __future__ import annotations
import base64
import hashlib
import os import os
import re import re
from collections.abc import Callable from collections.abc import Callable
@@ -444,42 +442,17 @@ class ReviewSession:
from the reviewed ref on every PR review (the bug that made the PR path from the reviewed ref on every PR review (the bug that made the PR path
unable to sign at all, and forced everyone onto the vacuous unable to sign at all, and forced everyone onto the vacuous
main-vs-itself path instead). main-vs-itself path instead).
`reattest` marks a KEY-ROTATION re-attestation pass (issue #68 finding 5
follow-up): the currently-trusted `bcc_core.CATALOG_PUBKEYS` key changed
and the existing `data/catalog.json.sig` no longer verifies under it.
Content-wise nothing may have changed -- `diff_catalogs(old, new)` can be
genuinely empty -- but the NEW key has never vouched for any of this
catalog before, so every entry needs a first-time attestation under the
new key, not a diff against the old one. When `reattest` is set,
`changes` is built as "every entry in `new_catalog`, presented as if
newly added" (via `diff_catalogs(None, new_catalog)`) instead of a
diff against `old_catalog`, so the acknowledge-gate in `can_sign()`
requires re-reviewing everything the new key will sign -- which is the
intended cost of a key rotation, not a bypass of the empty-diff guard.
""" """
pinned_blob_sha: str pinned_blob_sha: str
old_catalog: dict old_catalog: dict
new_catalog: dict new_catalog: dict
loaded_ref: str = "main" loaded_ref: str = "main"
reattest: bool = False
changes: list[EntryChange] = field(default_factory=list) changes: list[EntryChange] = field(default_factory=list)
acknowledged: set[str] = field(default_factory=set) acknowledged: set[str] = field(default_factory=set)
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not self.changes: if not self.changes:
if self.reattest:
# Every entry in the new catalog is treated as though it
# were newly added -- because, under the NEW signing key,
# it is: nothing this key signs was ever attested by it
# before. Reusing diff_catalogs(None, new) (rather than a
# bespoke code path) means the same "added" risk predicate
# (risk_new_entry) and the same EntryChange shape the rest
# of this module and the GUI already know how to render
# apply here unmodified.
self.changes = diff_catalogs(None, self.new_catalog)
else:
self.changes = diff_catalogs(self.old_catalog, self.new_catalog) self.changes = diff_catalogs(self.old_catalog, self.new_catalog)
@@ -488,14 +461,12 @@ def start_review(
old_catalog: dict, old_catalog: dict,
new_catalog: dict, new_catalog: dict,
loaded_ref: str = "main", loaded_ref: str = "main",
reattest: bool = False,
) -> ReviewSession: ) -> ReviewSession:
return ReviewSession( return ReviewSession(
pinned_blob_sha=pinned_blob_sha, pinned_blob_sha=pinned_blob_sha,
old_catalog=old_catalog, old_catalog=old_catalog,
new_catalog=new_catalog, new_catalog=new_catalog,
loaded_ref=loaded_ref, loaded_ref=loaded_ref,
reattest=reattest,
) )
@@ -560,14 +531,6 @@ def can_sign(session: ReviewSession, current_blob_sha: str) -> SignDecision:
mean "nothing to sign", never "sign unlocked". (Issue #68 finding 1; mean "nothing to sign", never "sign unlocked". (Issue #68 finding 1;
this is what let commit b08cf21 sign all 19 entries with zero of them this is what let commit b08cf21 sign all 19 entries with zero of them
ever reviewed.) ever reviewed.)
This gate is unaffected by `session.reattest`: a key-rotation
re-attestation session's `changes` is built from
`diff_catalogs(None, new_catalog)` (see ReviewSession), which is
empty ONLY if the catalog itself has zero entries -- a genuinely
empty catalog either way. Rotation never manufactures a non-empty
changeset out of an empty one; it just changes *what* "non-empty"
is computed against.
1. TOCTOU: `current_blob_sha` (fetched fresh, immediately before signing, 1. TOCTOU: `current_blob_sha` (fetched fresh, immediately before signing,
from the ref that was actually reviewed -- see sign_precondition()) from the ref that was actually reviewed -- see sign_precondition())
must match the blob SHA pinned when review began. If the bytes on the must match the blob SHA pinned when review began. If the bytes on the
@@ -853,243 +816,3 @@ _NON_ASCII_RE = re.compile(r"[^\x00-\x7f]")
def contains_non_ascii(s: str) -> bool: def contains_non_ascii(s: str) -> bool:
return bool(_NON_ASCII_RE.search(s)) return bool(_NON_ASCII_RE.search(s))
# --------------------------------------------------------------------------- #
# Key status reporting (issue #62/#68 follow-up: "make key handling
# comprehensible"). Pure functions only -- `catalog_console.py cmd_keys` is a
# thin printer that gathers inputs (local key caches, source-file text, the
# catalog + its .sig) and hands them here. NEVER touches private key bytes:
# every input/output here is a public key, a fingerprint, or a status string.
# --------------------------------------------------------------------------- #
def fingerprint_pubkey(pubkey: bytes) -> str:
"""Short, human-comparable fingerprint of a raw Ed25519 public key: the
first 16 hex chars of its SHA-256 digest, grouped in 4s (e.g. "3F2A 9C1B
44DE 08AA") so two fingerprints can be eyeballed for a mismatch the way a
PGP fingerprint is. Deliberately NOT the raw base64 pubkey itself in the
default short form (that's available via the full committed value in the
report) -- a fixed-width grouped hex string is easier to compare at a
glance and to read aloud/type over chat if needed. Never derived from,
and never printed alongside, any private key material.
"""
digest = hashlib.sha256(pubkey).hexdigest().upper()[:16]
return " ".join(digest[i : i + 4] for i in range(0, len(digest), 4))
_PUBKEY_LIST_B64_RE = re.compile(r'base64\.b64decode\(\s*"([^"]+)"\s*\)')
def extract_pubkey_list_literal(source_text: str, var_name: str) -> list[bytes]:
"""Best-effort extraction of a `<var_name>: list[bytes] = [...]` literal
(each entry a `base64.b64decode("...")` call, matching the exact style
bcc_core.CATALOG_PUBKEYS and scripts.sign_checksums.RELEASE_PUBKEYS are
both written in) straight out of Python source TEXT.
Deliberately a regex over text, not an import: `catalog_console.py keys`
must report on whatever ref/branch is checked out at the inspected repo
path, which may not be (and need not be) importable from the running
process's own sys.path. Returns [] if the variable isn't found in this
exact shape -- callers treat that as "nothing committed here", not an
error, since a report that can't parse a file should say so plainly
rather than crash the whole `keys` command over one malformed file.
"""
match = re.search(
rf"{re.escape(var_name)}\s*:\s*list\[bytes\]\s*=\s*\[(.*?)\]", source_text, re.DOTALL
)
if not match:
return []
keys: list[bytes] = []
for b64 in _PUBKEY_LIST_B64_RE.findall(match.group(1)):
try:
keys.append(base64.b64decode(b64))
except ValueError:
continue
return keys
_CI_TRUST_ANCHOR_RE = re.compile(r'EXPECTED_CATALOG_PUBKEY_B64:\s*"([^"]+)"')
def extract_ci_trust_anchor_pubkey(ci_yml_text: str) -> bytes | None:
"""Best-effort extraction of ci.yml's `EXPECTED_CATALOG_PUBKEY_B64` trust
anchor (issue #68 finding 4) from the workflow file's TEXT. Returns None
if the constant isn't found -- the `keys` report shows that plainly
("not found in ci.yml") rather than raising.
"""
match = _CI_TRUST_ANCHOR_RE.search(ci_yml_text)
if not match:
return None
try:
return base64.b64decode(match.group(1))
except ValueError:
return None
@dataclass(frozen=True)
class PubkeyLocationCheck:
"""One place in the source tree a key's public half is expected to be
committed, and whether the fingerprint(s) found there match the key
stored locally."""
location: str
committed_fingerprints: tuple[str, ...]
status: str # "match" | "mismatch" | "unknown" (no local key to compare against)
@dataclass(frozen=True)
class KeyStatus:
"""Everything `catalog_console.py keys` reports about ONE signing key.
Built by key_status() below; rendered by render_key_status_report().
Never carries private key material -- every field here is safe to print.
"""
kind: str # "catalog" | "release"
display_name: str # "CATALOG" | "RELEASE"
purpose: str # one-line plain-English purpose
private_key_location: str # human-readable, e.g. "on this machine, in the OS keychain"
local_exists: bool
local_fingerprint: str | None
locations: tuple[PubkeyLocationCheck, ...]
catalog_sig_status: str | None = None # "valid" | "invalid" | "missing" | None (n/a)
def key_status(
kind: str,
*,
display_name: str,
purpose: str,
private_key_location: str,
local_exists: bool,
local_pubkey: bytes | None,
locations: list[tuple[str, list[bytes]]],
catalog_sig_status: str | None = None,
) -> KeyStatus:
"""Pure assembly of a KeyStatus from already-resolved inputs (no file or
git I/O here -- that's catalog_console.py's job). `locations` is a list
of (label, committed_pubkeys) pairs, e.g.
[("bcc_core.CATALOG_PUBKEYS", [...]), ("ci.yml trust anchor", [...])],
so a key can be checked against every place its public half is expected
to be committed, independently -- this is the check that would have
caught bcc_core.CATALOG_PUBKEYS and ci.yml's trust anchor silently
drifting apart (issue #68 finding 4 was exactly that kind of drift).
"""
checks: list[PubkeyLocationCheck] = []
for label, committed_pubkeys in locations:
fps = tuple(fingerprint_pubkey(pk) for pk in committed_pubkeys)
if local_pubkey is None:
status = "unknown"
elif local_pubkey in committed_pubkeys:
status = "match"
else:
status = "mismatch"
checks.append(
PubkeyLocationCheck(location=label, committed_fingerprints=fps, status=status)
)
return KeyStatus(
kind=kind,
display_name=display_name,
purpose=purpose,
private_key_location=private_key_location,
local_exists=local_exists,
local_fingerprint=fingerprint_pubkey(local_pubkey) if local_pubkey is not None else None,
locations=tuple(checks),
catalog_sig_status=catalog_sig_status,
)
_LOCATION_STATUS_ICON = {"match": "", "mismatch": "", "unknown": "⚠️"}
_LOCATION_STATUS_VERDICT = {
"match": "MATCHES the local private key",
"mismatch": "DOES NOT MATCH the local private key",
"unknown": "cannot compare -- no local key to check against",
}
_CATALOG_SIG_STATUS_LINE = {
"valid": "✅ data/catalog.json.sig verifies under the committed CATALOG_PUBKEYS.",
"invalid": (
"❌ data/catalog.json.sig does NOT verify under the committed CATALOG_PUBKEYS -- "
"the catalog needs re-signing (Load → acknowledge all → Sign)."
),
"missing": (
"⚠️ data/catalog.json.sig is missing entirely -- the catalog has never been signed."
),
}
def recommend_next_steps(statuses: list[KeyStatus]) -> list[str]:
"""The pure "what to do next" logic behind the keys report's closing
section -- one concrete, runnable-looking instruction per problem found,
naming the exact key involved (never just "the key"). Returns a single
reassuring line if nothing needs attention."""
steps: list[str] = []
for s in statuses:
if not s.local_exists:
flag = " --release" if s.kind == "release" else ""
steps.append(
f"{s.display_name} key has never been generated on this machine -- run "
f"`python catalog_console.py keygen{flag}`."
)
continue
for loc in s.locations:
if loc.status == "mismatch":
steps.append(
f"{s.display_name} key's local fingerprint does not match "
f"{loc.location} -- update {loc.location} to the fingerprint shown "
"above (or, if this is unexpected, treat the committed key as "
"untrusted and investigate before doing anything else)."
)
elif loc.status == "unknown":
steps.append(
f"{s.display_name} key's local fingerprint could not be checked against "
f"{loc.location} -- re-run keygen (or, for an older install, unlock the "
"key once) so its public half is cached locally."
)
if s.kind == "catalog" and s.catalog_sig_status in ("invalid", "missing"):
steps.append(
"The catalog needs re-signing: run `python catalog_console.py gui --repo .` "
"and Load → acknowledge every entry → Sign. If main is red because "
"of a key rotation, load the branch with the rotation instead of main "
"(current-branch / --ref source) so the fix lands before merge."
)
if not steps:
steps.append("Everything is consistent -- no action needed.")
return steps
def render_key_status_report(statuses: list[KeyStatus]) -> str:
"""Render a full, plain-English-first key status report as one string.
`catalog_console.py cmd_keys` prints this verbatim -- the CLI is a thin
printer over this pure function, which is what makes the report's
content (not just its plumbing) unit-testable."""
lines: list[str] = []
for s in statuses:
lines.append(f"=== {s.display_name} KEY ===")
lines.append(s.purpose)
lines.append(f"Private half lives: {s.private_key_location}")
if s.local_exists and s.local_fingerprint:
lines.append(f"Exists locally: yes (fingerprint {s.local_fingerprint})")
elif s.local_exists:
lines.append("Exists locally: yes (fingerprint unknown -- re-run keygen to cache it)")
else:
lines.append("Exists locally: no")
for loc in s.locations:
icon = _LOCATION_STATUS_ICON.get(loc.status, "?")
fps = (
", ".join(loc.committed_fingerprints)
if loc.committed_fingerprints
else "(nothing committed here)"
)
verdict = _LOCATION_STATUS_VERDICT.get(loc.status, loc.status)
lines.append(f" {icon} {loc.location}: {fps} -- {verdict}")
if s.catalog_sig_status is not None:
lines.append(
f"Catalog signature: {_CATALOG_SIG_STATUS_LINE.get(s.catalog_sig_status, s.catalog_sig_status)}"
)
lines.append("")
lines.append("What to do next:")
for step in recommend_next_steps(statuses):
lines.append(f" - {step}")
return "\n".join(lines)
+6 -10
View File
@@ -53,17 +53,13 @@ DOMAIN_PREFIX = b"bcc-release-v1|"
# the signature on every past release: verification accepts a match against # the signature on every past release: verification accepts a match against
# ANY key here. # ANY key here.
# #
# Populated by the maintainer via: # Empty until the maintainer generates the release keypair (separately from
# the catalog keypair) and pastes the public half in:
# python catalog_console.py keygen --release # python catalog_console.py keygen --release
# Rotated 2026-07 (issue #68 finding 5 / #68 CI-exposure incident): the # This is intentionally NOT pre-populated with a placeholder that looks
# original key was shared with the catalog key and had been exposed to CI, # like a real key -- release.yml's signing-smoke-test fails closed (loudly)
# so both keypairs were regenerated as separate, disjoint keys. This list # on an empty list rather than silently verifying against nothing.
# holds only the current release key -- if release.yml's signing-smoke-test RELEASE_PUBKEYS: list[bytes] = []
# ever sees this list empty, it fails closed (loudly) rather than silently
# verifying against nothing.
RELEASE_PUBKEYS: list[bytes] = [
base64.b64decode("6BnPgJEHJFyVltFoLTCNadIsehjy00iiW8IRlC1TfhA="),
]
CHUNK_SIZE = 1024 * 1024 CHUNK_SIZE = 1024 * 1024
-215
View File
@@ -1,215 +0,0 @@
"""Tests for catalog_console.py's non-Qt git plumbing and ref-resolution
seam (issue #68 rotation-completability fix).
catalog_console.py is importable here WITHOUT PySide6 -- its Qt import is
guarded (`_PYSIDE6_AVAILABLE`) precisely so `keygen`, `show-seed-b64`,
`keys`, and this git plumbing stay usable (and testable) wherever PySide6
isn't installed, including this CI test job, which never installs it. If
PySide6 genuinely isn't importable in this environment, that itself
exercises the guard path -- see test_module_imports_without_pyside6.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import catalog_console as cc
import catalog_review as review
_SEED_CATALOG = b'{"schema": 1, "version": 1, "servers": []}'
_SEED_SIG = b"\x00" * 64
def _run(*args: str, cwd: Path) -> None:
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
def _init_bare_and_clone(tmp_path: Path) -> tuple[Path, Path]:
"""A bare "origin" repo with `main` and `rotation-branch` both seeded
with a catalog + (dummy) signature, plus a working clone with `origin`
already configured -- mirroring the tokened-remote clone
catalog_console.py's git plumbing is always run against."""
origin = tmp_path / "origin.git"
_run("init", "--bare", str(origin), cwd=tmp_path)
seed = tmp_path / "seed"
_run("clone", str(origin), str(seed), cwd=tmp_path)
_run("config", "user.email", "test@example.com", cwd=seed)
_run("config", "user.name", "Test", cwd=seed)
(seed / "data").mkdir()
(seed / "data" / "catalog.json").write_bytes(_SEED_CATALOG)
(seed / "data" / "catalog.json.sig").write_bytes(_SEED_SIG)
_run("add", "-A", cwd=seed)
_run("commit", "-m", "seed", cwd=seed)
_run("push", "origin", "HEAD:refs/heads/main", cwd=seed)
_run("checkout", "-b", "rotation-branch", cwd=seed)
_run("push", "origin", "HEAD:refs/heads/rotation-branch", cwd=seed)
clone = tmp_path / "work"
_run("clone", str(origin), str(clone), cwd=tmp_path)
_run("config", "user.email", "test@example.com", cwd=clone)
_run("config", "user.name", "Test", cwd=clone)
return origin, clone
# --------------------------------------------------------------------------- #
# The module must stay importable without PySide6 -- this IS the fix that
# lets `keys`/`keygen`/`show-seed-b64` (and this whole test file) run
# somewhere PySide6 isn't installed.
# --------------------------------------------------------------------------- #
def test_module_imports_without_pyside6():
assert hasattr(cc, "_PYSIDE6_AVAILABLE")
# This CI test job never installs PySide6 (see .github/workflows/ci.yml
# "Install test dependencies": pytest + cryptography only) -- so on CI,
# this assertion is itself proof the guard is doing its job. Locally,
# where a maintainer's env DOES have PySide6, it's fine either way; the
# only real assertion this test needs is "importing the module never
# raises", which happened just by getting this far.
assert cc._PYSIDE6_AVAILABLE in (True, False)
def test_cmd_gui_fails_soft_without_pyside6(monkeypatch, capsys):
if cc._PYSIDE6_AVAILABLE:
return # nothing to prove where PySide6 IS available
import argparse
args = argparse.Namespace(repo=".", ref=None)
assert cc.cmd_gui(args) == 1
assert "PySide6" in capsys.readouterr().err
# --------------------------------------------------------------------------- #
# compute_own_refs: the PURE ref-resolution seam. No git, no Qt.
# --------------------------------------------------------------------------- #
def test_compute_own_refs_defaults_to_main_only():
assert cc.compute_own_refs(None, None) == ["main"]
def test_compute_own_refs_adds_detected_branch():
assert cc.compute_own_refs(None, "chore/68-key-rotation") == [
"main",
"chore/68-key-rotation",
]
def test_compute_own_refs_explicit_ref_overrides_detected_branch():
assert cc.compute_own_refs("explicit-branch", "detected-branch") == [
"main",
"explicit-branch",
]
def test_compute_own_refs_does_not_duplicate_main():
assert cc.compute_own_refs(None, "main") == ["main"]
assert cc.compute_own_refs("main", "some-other-branch") == ["main"]
# --------------------------------------------------------------------------- #
# current_branch: git plumbing, no Qt.
# --------------------------------------------------------------------------- #
def test_current_branch_detects_checked_out_branch(tmp_path):
_origin, clone = _init_bare_and_clone(tmp_path)
_run("fetch", "origin", "rotation-branch", cwd=clone)
_run("checkout", "-B", "rotation-branch", "origin/rotation-branch", cwd=clone)
assert cc.current_branch(clone) == "rotation-branch"
def test_current_branch_none_on_detached_head(tmp_path):
_origin, clone = _init_bare_and_clone(tmp_path)
commit = cc.fetch_ref(clone, "main")
_run("checkout", commit, cwd=clone)
assert cc.current_branch(clone) is None
# --------------------------------------------------------------------------- #
# commit_and_push_signed_catalog: MUST target the given branch, never a
# hardcoded "main" -- issue #68's completability fix. This is exactly the
# bug that, before the fix, would have made ReviewWindow._on_sign push a
# PR/branch review's signature straight to main regardless of what was
# actually reviewed.
# --------------------------------------------------------------------------- #
def test_commit_and_push_signed_catalog_targets_the_given_branch_not_main(tmp_path):
_origin, clone = _init_bare_and_clone(tmp_path)
new_raw = b'{"schema": 1, "version": 2, "servers": []}'
new_sig = b"\x01" * 64
cc.commit_and_push_signed_catalog(clone, new_raw, new_sig, branch="rotation-branch")
rotation_commit = cc.fetch_ref(clone, "rotation-branch")
rotation_raw, _sha = cc.read_catalog_at_commit(clone, rotation_commit)
assert rotation_raw == new_raw
# main on the shared origin must be COMPLETELY untouched by a sign that
# was reviewed and pushed against rotation-branch.
main_commit = cc.fetch_ref(clone, "main")
main_raw, _sha = cc.read_catalog_at_commit(clone, main_commit)
assert main_raw == _SEED_CATALOG
def test_commit_and_push_signed_catalog_still_defaults_to_main(tmp_path):
"""Backward-compatible default: callers that don't pass `branch` (there
are none left in catalog_console.py itself, but the signature keeps the
default for any other caller / test fixture) still push to main."""
_origin, clone = _init_bare_and_clone(tmp_path)
new_raw = b'{"schema": 1, "version": 2, "servers": []}'
new_sig = b"\x01" * 64
cc.commit_and_push_signed_catalog(clone, new_raw, new_sig)
main_commit = cc.fetch_ref(clone, "main")
main_raw, _sha = cc.read_catalog_at_commit(clone, main_commit)
assert main_raw == new_raw
rotation_commit = cc.fetch_ref(clone, "rotation-branch")
rotation_raw, _sha = cc.read_catalog_at_commit(clone, rotation_commit)
assert rotation_raw == _SEED_CATALOG # untouched
# --------------------------------------------------------------------------- #
# catalog_sig_status_on_disk: the check behind `keys`' "does catalog.json.sig
# currently verify?" line -- this is precisely the check that would have
# caught the current chore/68-key-rotation state (bcc_core.CATALOG_PUBKEYS
# rotated, data/catalog.json.sig still signed by the retired key).
# --------------------------------------------------------------------------- #
def test_catalog_sig_status_on_disk_valid(tmp_path):
seed, pub = review.generate_keypair()
raw = b'{"schema": 1, "version": 1, "servers": []}'
sig = review.sign_catalog_bytes(raw, seed)
(tmp_path / "data").mkdir()
(tmp_path / "data" / "catalog.json").write_bytes(raw)
(tmp_path / "data" / "catalog.json.sig").write_bytes(sig)
assert cc.catalog_sig_status_on_disk(tmp_path, [pub]) == "valid"
def test_catalog_sig_status_on_disk_invalid_when_pubkey_rotated(tmp_path):
"""The exact chore/68-key-rotation scenario: signed by an OLD key, but
the committed pubkey list now only has the NEW key."""
old_seed, _old_pub = review.generate_keypair()
_new_seed, new_pub = review.generate_keypair()
raw = b'{"schema": 1, "version": 1, "servers": []}'
sig = review.sign_catalog_bytes(raw, old_seed)
(tmp_path / "data").mkdir()
(tmp_path / "data" / "catalog.json").write_bytes(raw)
(tmp_path / "data" / "catalog.json.sig").write_bytes(sig)
assert cc.catalog_sig_status_on_disk(tmp_path, [new_pub]) == "invalid"
def test_catalog_sig_status_on_disk_missing_when_no_sig_file(tmp_path):
(tmp_path / "data").mkdir()
(tmp_path / "data" / "catalog.json").write_bytes(b"{}")
assert cc.catalog_sig_status_on_disk(tmp_path, []) == "missing"
def test_catalog_sig_status_on_disk_missing_when_no_catalog_file(tmp_path):
(tmp_path / "data").mkdir()
(tmp_path / "data" / "catalog.json.sig").write_bytes(b"\x00" * 64)
assert cc.catalog_sig_status_on_disk(tmp_path, []) == "missing"
-334
View File
@@ -491,98 +491,6 @@ def test_sign_precondition_defaults_to_main_when_loaded_ref_unset():
assert decision.ok is True assert decision.ok is True
# --------------------------------------------------------------------------- #
# ReviewSession(reattest=True): key-rotation re-attestation (issue #68
# finding 5 follow-up). After rotating bcc_core.CATALOG_PUBKEYS, the
# existing data/catalog.json.sig no longer verifies under the new key even
# though catalog CONTENT is unchanged -- diff_catalogs(old, new) would be
# empty, and an empty changeset must never unlock Sign (can_sign gate 0).
# Re-attestation mode sidesteps that correctly: instead of diffing against
# the (now-untrustworthy) last-signed content, it treats every entry as
# requiring a fresh acknowledgement, exactly like a brand-new catalog.
# --------------------------------------------------------------------------- #
def test_reattest_mode_with_unchanged_content_yields_one_change_per_entry():
same = _catalog(_entry(id="a"), _entry(id="b"), _entry(id="c"))
session = r.start_review("sha1", same, same, reattest=True)
assert len(session.changes) == 3
assert {c_.entry_id for c_ in session.changes} == {"a", "b", "c"}
# Presented "as if newly added" -- old_catalog plays no role here.
assert all(c_.status == "added" for c_ in session.changes)
assert all(c_.old is None for c_ in session.changes)
def test_reattest_mode_can_sign_refuses_until_all_acknowledged_then_permits():
same = _catalog(_entry(id="a"), _entry(id="b"), _entry(id="c"))
session = r.start_review("sha1", same, same, reattest=True)
decision = r.can_sign(session, "sha1")
assert decision.ok is False
assert "acknowledged" in decision.reason.lower()
r.acknowledge_entry(session, "a")
r.acknowledge_entry(session, "b")
decision = r.can_sign(session, "sha1")
assert decision.ok is False # "c" still outstanding
assert "acknowledged" in decision.reason.lower()
r.acknowledge_entry(session, "c")
decision = r.can_sign(session, "sha1")
assert decision.ok is True
assert decision.reason is None
def test_normal_mode_with_unchanged_content_still_refuses_empty_diff():
"""The rotation path must NOT become a general bypass of the empty-diff
guard: reattest=False (the default) against identical old/new catalogs
must behave exactly as before -- can_sign refuses with 'nothing to
sign', full stop."""
same = _catalog(_entry(id="a"), _entry(id="b"))
session = r.start_review("sha1", same, same) # reattest defaults False
assert session.changes == []
decision = r.can_sign(session, "sha1")
assert decision.ok is False
assert "nothing to sign" in decision.reason.lower()
def test_reattest_mode_still_enforces_blocking_risk_check():
"""Re-attestation must not relax the blocking-risk gate: a
disallowed-command entry blocks Sign even with every entry
acknowledged."""
cat = _catalog(
_entry(id="a"),
_entry(id="evil", config={"command": "bash", "args": ["-c", "rm -rf /"]}),
)
session = r.start_review("sha1", cat, cat, reattest=True)
assert len(session.changes) == 2
r.acknowledge_entry(session, "a")
r.acknowledge_entry(session, "evil")
assert r.all_entries_acknowledged(session) is True
decision = r.can_sign(session, "sha1")
assert decision.ok is False
assert "blocking" in decision.reason.lower()
def test_reattest_mode_still_enforces_toctou_pin():
"""Re-attestation must not relax the TOCTOU blob-SHA pin: acknowledging
everything is not enough if the bytes moved underneath the review."""
same = _catalog(_entry(id="a"))
session = r.start_review("sha1", same, same, reattest=True)
r.acknowledge_entry(session, "a")
decision = r.can_sign(session, "sha1")
assert decision.ok is True # sanity: matches when blob is unchanged
decision = r.can_sign(session, "sha2-a-new-commit-landed")
assert decision.ok is False
assert "mismatch" in decision.reason.lower() or "changed" in decision.reason.lower()
def test_reattest_defaults_to_false():
"""start_review()'s reattest parameter defaults to False -- normal
(diff-based) review is the default behaviour, never silently entered."""
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
assert session.reattest is False
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# find_last_signed_catalog_raw: what source=main diffs against # find_last_signed_catalog_raw: what source=main diffs against
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -785,245 +693,3 @@ def test_contains_non_ascii_true():
def test_contains_non_ascii_false(): def test_contains_non_ascii_false():
assert r.contains_non_ascii("package") is False assert r.contains_non_ascii("package") is False
# --------------------------------------------------------------------------- #
# fingerprint_pubkey
# --------------------------------------------------------------------------- #
def test_fingerprint_pubkey_is_deterministic():
pub = b"\x01" * 32
assert r.fingerprint_pubkey(pub) == r.fingerprint_pubkey(pub)
def test_fingerprint_pubkey_differs_for_different_keys():
assert r.fingerprint_pubkey(b"\x01" * 32) != r.fingerprint_pubkey(b"\x02" * 32)
def test_fingerprint_pubkey_never_contains_the_key_bytes_themselves():
pub = b"\x42" * 32
fp = r.fingerprint_pubkey(pub)
assert pub.hex() not in fp.lower().replace(" ", "")
# --------------------------------------------------------------------------- #
# extract_pubkey_list_literal / extract_ci_trust_anchor_pubkey: text parsing
# for `catalog_console.py keys`, exercised here with no file I/O.
# --------------------------------------------------------------------------- #
def test_extract_pubkey_list_literal_single_key():
_seed, pub = r.generate_keypair()
import base64
text = (
"CATALOG_PUBKEYS: list[bytes] = [\n"
f' base64.b64decode("{base64.b64encode(pub).decode()}"),\n'
"]\n"
)
assert r.extract_pubkey_list_literal(text, "CATALOG_PUBKEYS") == [pub]
def test_extract_pubkey_list_literal_multiple_keys():
import base64
pubs = [r.generate_keypair()[1] for _ in range(2)]
body = ",\n".join(f' base64.b64decode("{base64.b64encode(p).decode()}")' for p in pubs)
text = f"RELEASE_PUBKEYS: list[bytes] = [\n{body},\n]\n"
assert r.extract_pubkey_list_literal(text, "RELEASE_PUBKEYS") == pubs
def test_extract_pubkey_list_literal_missing_variable_returns_empty():
assert r.extract_pubkey_list_literal("some unrelated text", "CATALOG_PUBKEYS") == []
def test_extract_pubkey_list_literal_does_not_match_a_different_variable():
import base64
_seed, pub = r.generate_keypair()
text = f'OTHER_PUBKEYS: list[bytes] = [base64.b64decode("{base64.b64encode(pub).decode()}")]\n'
assert r.extract_pubkey_list_literal(text, "CATALOG_PUBKEYS") == []
def test_extract_ci_trust_anchor_pubkey_found():
import base64
_seed, pub = r.generate_keypair()
text = f' EXPECTED_CATALOG_PUBKEY_B64: "{base64.b64encode(pub).decode()}"\n'
assert r.extract_ci_trust_anchor_pubkey(text) == pub
def test_extract_ci_trust_anchor_pubkey_missing_returns_none():
assert r.extract_ci_trust_anchor_pubkey("no anchor here") is None
# --------------------------------------------------------------------------- #
# key_status / render_key_status_report / recommend_next_steps
# --------------------------------------------------------------------------- #
def _kw(**overrides):
base = dict(
kind="catalog",
display_name="CATALOG",
purpose="Signs the catalog.",
private_key_location="on this machine",
local_exists=True,
local_pubkey=b"\x01" * 32,
locations=[("bcc_core.CATALOG_PUBKEYS", [b"\x01" * 32])],
catalog_sig_status="valid",
)
base.update(overrides)
return base
def test_key_status_reports_match_when_local_pubkey_in_committed_list():
status = r.key_status(**_kw())
assert status.locations[0].status == "match"
def test_key_status_reports_mismatch_when_local_pubkey_not_in_committed_list():
status = r.key_status(**_kw(locations=[("bcc_core.CATALOG_PUBKEYS", [b"\x02" * 32])]))
assert status.locations[0].status == "mismatch"
def test_key_status_reports_unknown_when_no_local_pubkey():
status = r.key_status(**_kw(local_pubkey=None, local_exists=False))
assert status.locations[0].status == "unknown"
assert status.local_fingerprint is None
def test_key_status_never_carries_a_local_fingerprint_when_key_absent():
status = r.key_status(**_kw(local_pubkey=None, local_exists=False))
assert status.local_exists is False
assert status.local_fingerprint is None
def test_key_status_fingerprint_matches_fingerprint_pubkey_helper():
pub = b"\x03" * 32
status = r.key_status(**_kw(local_pubkey=pub, locations=[("x", [pub])]))
assert status.local_fingerprint == r.fingerprint_pubkey(pub)
def test_key_status_checks_multiple_locations_independently():
"""A key can match one committed location and mismatch another -- this
is exactly the drift issue #68 finding 4 was about (bcc_core.py and
ci.yml silently disagreeing on the trust anchor)."""
pub = b"\x04" * 32
other = b"\x05" * 32
status = r.key_status(
**_kw(
local_pubkey=pub,
locations=[
("bcc_core.CATALOG_PUBKEYS", [pub]),
("ci.yml trust anchor", [other]),
],
)
)
assert status.locations[0].status == "match"
assert status.locations[1].status == "mismatch"
def test_recommend_next_steps_flags_never_generated_key():
status = r.key_status(**_kw(local_exists=False, local_pubkey=None, locations=[]))
steps = r.recommend_next_steps([status])
assert any("keygen" in s and "CATALOG" in s for s in steps)
def test_recommend_next_steps_release_key_uses_release_flag():
status = r.key_status(
kind="release",
display_name="RELEASE",
purpose="Signs checksums.",
private_key_location="not generated yet",
local_exists=False,
local_pubkey=None,
locations=[],
)
steps = r.recommend_next_steps([status])
assert any("keygen --release" in s for s in steps)
def test_recommend_next_steps_flags_mismatch_by_location_name():
status = r.key_status(**_kw(locations=[("bcc_core.CATALOG_PUBKEYS", [b"\x99" * 32])]))
steps = r.recommend_next_steps([status])
assert any("bcc_core.CATALOG_PUBKEYS" in s and "CATALOG" in s for s in steps)
def test_recommend_next_steps_flags_invalid_catalog_signature():
status = r.key_status(**_kw(catalog_sig_status="invalid"))
steps = r.recommend_next_steps([status])
assert any("re-signing" in s for s in steps)
def test_recommend_next_steps_flags_missing_catalog_signature():
status = r.key_status(**_kw(catalog_sig_status="missing"))
steps = r.recommend_next_steps([status])
assert any("re-signing" in s for s in steps)
def test_recommend_next_steps_all_clear_when_nothing_wrong():
status = r.key_status(**_kw())
steps = r.recommend_next_steps([status])
assert steps == ["Everything is consistent -- no action needed."]
def test_recommend_next_steps_release_key_has_no_catalog_signature_advice():
"""A mismatched RELEASE key must never trigger catalog-signing advice --
the two keys' remediation paths must not bleed into each other."""
status = r.key_status(
kind="release",
display_name="RELEASE",
purpose="Signs checksums.",
private_key_location="on this machine",
local_exists=True,
local_pubkey=b"\x06" * 32,
locations=[("scripts/sign_checksums.py RELEASE_PUBKEYS", [b"\x07" * 32])],
catalog_sig_status=None,
)
steps = r.recommend_next_steps([status])
assert not any("re-signing" in s for s in steps)
assert any("RELEASE" in s for s in steps)
def test_render_key_status_report_never_prints_private_key_material():
"""The report string must be built ONLY from public inputs. Sanity
check: no field on KeyStatus/PubkeyLocationCheck is capable of holding
private key bytes in the first place (there's no such field to leak),
and the render function only touches fields that exist -- this test
guards against a future field addition reintroducing that risk."""
status = r.key_status(**_kw())
text = r.render_key_status_report([status])
assert "CATALOG" in text
assert (
"purpose" not in text.lower() or "Signs the catalog." in text
) # sanity, not a real secret
# No 64-hex-char (or longer) run anywhere -- a raw 32-byte seed/sig
# would show up as one if it were ever accidentally interpolated in.
import re as _re
assert not _re.search(r"[0-9a-fA-F]{64,}", text)
def test_render_key_status_report_names_the_specific_key_not_generic_the_key():
status = r.key_status(**_kw())
text = r.render_key_status_report([status])
assert "CATALOG KEY" in text
assert "the key" not in text.lower()
def test_render_key_status_report_includes_catalog_signature_line_only_for_catalog():
catalog_status = r.key_status(**_kw())
release_status = r.key_status(
kind="release",
display_name="RELEASE",
purpose="Signs checksums.",
private_key_location="on this machine",
local_exists=True,
local_pubkey=b"\x08" * 32,
locations=[("scripts/sign_checksums.py RELEASE_PUBKEYS", [b"\x08" * 32])],
catalog_sig_status=None,
)
text = r.render_key_status_report([catalog_status, release_status])
assert text.count("Catalog signature:") == 1
def test_render_key_status_report_ends_with_what_to_do_next_section():
status = r.key_status(**_kw())
text = r.render_key_status_report([status])
assert "What to do next:" in text
+445
View File
@@ -1,6 +1,7 @@
"""Pytest port of the original test_core.py script (same 23 behaviours, now """Pytest port of the original test_core.py script (same 23 behaviours, now
proper test functions with tmp_path/monkeypatch fixtures).""" proper test functions with tmp_path/monkeypatch fixtures)."""
import dataclasses
import json import json
import os import os
import re import re
@@ -2370,3 +2371,447 @@ def test_config_has_unfilled_placeholders_false_after_fill():
def test_config_has_unfilled_placeholders_checks_env_too(): def test_config_has_unfilled_placeholders_checks_env_too():
cfg = {"command": "uvx", "args": ["mcp-grafana"], "env": {"GRAFANA_URL": "<GRAFANA_URL>"}} cfg = {"command": "uvx", "args": ["mcp-grafana"], "env": {"GRAFANA_URL": "<GRAFANA_URL>"}}
assert c.config_has_unfilled_placeholders(cfg) is True assert c.config_has_unfilled_placeholders(cfg) is True
# --------------------------------------------------------------------------- #
# #72 -- a server value that isn't a JSON object must not take the load down
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("bad", ["not-a-dict", 123, ["a", "b"], None, True, 1.5])
def test_extract_servers_survives_non_dict_server_value(bad):
entries = c.extract_servers({"mcpServers": {"foo": bad}})
assert len(entries) == 1
assert entries[0].name == "foo"
assert entries[0].data == {}
assert entries[0].malformed is True
assert entries[0].raw == bad
def test_extract_servers_marks_only_the_bad_entry():
cfg = {"mcpServers": {"good": {"command": "npx"}, "bad": "oops"}}
by_name = {e.name: e for e in c.extract_servers(cfg)}
assert by_name["good"].malformed is False
assert by_name["good"].data == {"command": "npx"}
assert by_name["bad"].malformed is True
def test_extract_servers_handles_malformed_disabled_entry():
entries = c.extract_servers({c.DISABLED_KEY: {"parked": ["nope"]}})
assert entries[0].enabled is False
assert entries[0].malformed is True
def test_malformed_entry_round_trips_through_save_unchanged():
"""The cardinal rule: never silently delete what the user had on disk."""
cfg = {"mcpServers": {"good": {"command": "npx"}, "bad": "oops"}}
servers = c.extract_servers(cfg)
out = c.apply_servers(dict(cfg), servers)
assert out["mcpServers"]["bad"] == "oops"
assert out["mcpServers"]["good"] == {"command": "npx"}
def test_editing_a_malformed_entry_retires_the_raw_value():
entry = c.extract_servers({"mcpServers": {"bad": "oops"}})[0]
entry.set_data({"command": "npx"})
assert entry.malformed is False
assert entry.config_value() == {"command": "npx"}
assert c.apply_servers({}, [entry])["mcpServers"]["bad"] == {"command": "npx"}
def test_lint_reports_the_malformed_entry_by_name():
servers = c.extract_servers({"mcpServers": {"bad": "oops"}})
warnings = c.lint_servers(servers)
assert len(warnings) == 1
assert "'bad'" in warnings[0]
assert "not an object" in warnings[0]
assert "str" in warnings[0]
def test_lint_still_reports_normal_warnings_alongside_malformed():
cfg = {"mcpServers": {"bad": "oops", "sloppy": {"command": "npx", "args": "one two"}}}
warnings = c.lint_servers(c.extract_servers(cfg))
assert any("not an object" in w for w in warnings)
assert any("'args' should be a list" in w for w in warnings)
# --------------------------------------------------------------------------- #
# #73 -- the stale-file merge must not discard BCC-authored keys
# --------------------------------------------------------------------------- #
def test_carry_owned_keys_moves_sets_onto_the_reloaded_config():
local = {"mcpServers": {}, c.SETS_KEY: {"work": ["a", "b"]}}
fresh = {"mcpServers": {"external": {"command": "npx"}}}
contested = c.carry_owned_keys(local, fresh)
assert contested == []
assert fresh[c.SETS_KEY] == {"work": ["a", "b"]}
assert fresh["mcpServers"] == {"external": {"command": "npx"}}
def test_carry_owned_keys_reports_a_genuine_conflict():
local = {c.SETS_KEY: {"work": ["a"]}}
fresh = {c.SETS_KEY: {"work": ["a", "b"]}}
assert c.carry_owned_keys(local, fresh) == [c.SETS_KEY]
assert fresh[c.SETS_KEY] == {"work": ["a"]} # local wins: BCC owns the key
def test_carry_owned_keys_is_quiet_when_both_sides_agree():
local = {c.SETS_KEY: {"work": ["a"]}}
fresh = {c.SETS_KEY: {"work": ["a"]}}
assert c.carry_owned_keys(local, fresh) == []
def test_carry_owned_keys_leaves_disk_alone_when_absent_locally():
"""Can't distinguish 'deleted my last set' from 'never had sets'; keep theirs."""
fresh = {c.SETS_KEY: {"remote": ["a"]}}
assert c.carry_owned_keys({}, fresh) == []
assert fresh[c.SETS_KEY] == {"remote": ["a"]}
def test_carry_owned_keys_deep_copies_so_later_edits_do_not_leak():
local = {c.SETS_KEY: {"work": ["a"]}}
fresh = {}
c.carry_owned_keys(local, fresh)
local[c.SETS_KEY]["work"].append("b")
assert fresh[c.SETS_KEY] == {"work": ["a"]}
def test_merge_flow_preserves_sets_and_external_servers(tmp_path):
"""End-to-end shape of the Merge & save path that lost sets in #73."""
path = tmp_path / "claude.json"
path.write_text(json.dumps({"mcpServers": {"old": {"command": "old"}}}))
# BCC loads, user saves a named set and edits servers in memory.
local = c.load_config(path)
servers = c.extract_servers(local)
c.save_server_set(local, "work", servers)
# Something else rewrites the file underneath us.
path.write_text(json.dumps({"mcpServers": {"external": {"command": "new"}}, "other": 1}))
# Merge & save: reload disk, carry BCC keys, re-apply the user's servers.
fresh = c.load_config(path)
c.carry_owned_keys(local, fresh)
c.apply_servers(fresh, servers)
c.write_config(path, fresh)
saved = c.load_config(path)
assert saved[c.SETS_KEY] == {"work": ["old"]} # the set survived
assert saved["other"] == 1 # unrelated external key preserved
assert "old" in saved["mcpServers"] # user's servers re-applied
def test_null_server_value_is_malformed_not_mistaken_for_absent():
"""`{"mcpServers": {"foo": null}}` is legal JSON and a real malformed case,
so None must not double as the 'nothing here' sentinel."""
entry = c.extract_servers({"mcpServers": {"foo": None}})[0]
assert entry.malformed is True
assert entry.raw is None
assert c.apply_servers({}, [entry])["mcpServers"]["foo"] is None
def test_a_normal_entry_is_not_malformed():
entry = c.extract_servers({"mcpServers": {"foo": {"command": "npx"}}})[0]
assert entry.malformed is False
assert entry.raw is c.NO_RAW
# --------------------------------------------------------------------------- #
# #75 -- theming
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"setting,system_dark,expected",
[
(c.THEME_DARK, False, "dark"),
(c.THEME_DARK, True, "dark"),
(c.THEME_LIGHT, False, "light"),
(c.THEME_LIGHT, True, "light"),
(c.THEME_SYSTEM, True, "dark"),
(c.THEME_SYSTEM, False, "light"),
],
)
def test_resolve_theme_covers_every_setting_and_appearance(setting, system_dark, expected):
assert c.resolve_theme(setting, system_dark) == expected
@pytest.mark.parametrize("junk", ["", "solarized", None, "DARK", 3])
def test_resolve_theme_falls_back_to_following_the_system(junk):
"""A hand-edited or future QSettings value should follow the desktop,
not pin a fixed theme."""
assert c.resolve_theme(junk, True) == "dark"
assert c.resolve_theme(junk, False) == "light"
def test_palette_for_known_names():
assert c.palette_for("dark") is c.DARK_PALETTE
assert c.palette_for("light") is c.LIGHT_PALETTE
def test_palette_for_unknown_name_falls_back_to_dark():
assert c.palette_for("chartreuse") is c.DARK_PALETTE
def test_dark_palette_is_unchanged_from_the_shipped_look():
"""v1.3.0 shipped these exact colours; adding a light theme must not
quietly restyle the dark one."""
p = c.DARK_PALETTE
assert (p.accent, p.bg, p.panel, p.panel_2) == ("#f97316", "#1b1d23", "#23262e", "#2b2f39")
assert (p.text, p.muted, p.border) == ("#e7e9ee", "#9aa0ad", "#3a3f4b")
assert (p.good, p.bad, p.warn, p.remote) == ("#4ade80", "#f87171", "#fbbf24", "#60a5fa")
assert (p.on_accent, p.disabled_bg, p.mono_bg) == ("#1a1205", "#202229", "#16181d")
def test_both_palettes_define_every_slot():
"""A missing slot should fail here rather than render a broken window."""
for pal in (c.DARK_PALETTE, c.LIGHT_PALETTE):
for f in dataclasses.fields(c.Palette):
value = getattr(pal, f.name)
assert value, f"{pal.name}.{f.name} is empty"
if f.name != "name":
assert re.fullmatch(r"#[0-9a-fA-F]{6}", value), f"{pal.name}.{f.name}={value!r}"
@pytest.mark.parametrize("pal_name", ["dark", "light"])
@pytest.mark.parametrize("slot", ["text", "muted", "good", "bad", "warn", "remote", "accent"])
def test_palette_meets_contrast_on_panel(pal_name, slot):
"""Every colour drawn as text/glyph must clear WCAG AA (4.5:1) against the
surface it sits on. The light palette's semantic colours are NOT the dark
ones lightened -- #4ade80 sits near 1.7:1 on white -- so this guards
against someone 'harmonising' them back toward the dark hues."""
pal = c.palette_for(pal_name)
assert c.contrast_ratio(getattr(pal, slot), pal.panel) >= 4.5
@pytest.mark.parametrize("pal_name", ["dark", "light"])
def test_on_accent_is_legible_against_the_accent_fill(pal_name):
"""Primary buttons and selected rows draw on_accent on top of accent."""
pal = c.palette_for(pal_name)
assert c.contrast_ratio(pal.on_accent, pal.accent) >= 4.5
def test_contrast_ratio_endpoints():
assert c.contrast_ratio("#000000", "#ffffff") == pytest.approx(21.0, abs=0.01)
assert c.contrast_ratio("#123456", "#123456") == pytest.approx(1.0, abs=0.001)
assert c.contrast_ratio("#ffffff", "#000000") == pytest.approx(21.0, abs=0.01)
def test_relative_luminance_extremes():
assert c.relative_luminance("#000000") == pytest.approx(0.0)
assert c.relative_luminance("#ffffff") == pytest.approx(1.0)
def test_stylesheet_builder_has_no_hardcoded_colours():
"""Every colour in the QSS must come from the palette.
Three near-black literals used to be inlined here (#1a1205, #202229,
#16181d). Harmless with one theme; with two, they silently render dark
chrome on a light window. Reads the source rather than importing bcc,
which needs PySide6.
"""
src = (Path(__file__).resolve().parent.parent / "bcc.py").read_text(encoding="utf-8")
start = src.index("def build_stylesheet")
body = src[start : src.index("def apply_palette")]
assert re.findall(r"#[0-9a-fA-F]{6}", body) == []
def test_every_palette_slot_is_consumed():
"""A slot added to Palette but never wired up is dead weight.
Checks for `p.<slot>` anywhere in bcc.py, which covers both the QSS and
apply_palette's global bindings -- not every slot belongs in the
stylesheet (`good` and `remote` feed the inline status dots via
STATUS_COLORS/HEALTH_COLORS, never the QSS). This won't catch a slot bound
to a global that nothing then uses; it does catch the common mistake of
extending the dataclass and forgetting to plumb it through.
"""
src = (Path(__file__).resolve().parent.parent / "bcc.py").read_text(encoding="utf-8")
for f in dataclasses.fields(c.Palette):
if f.name == "name":
continue
assert f"p.{f.name}" in src, f"palette slot {f.name!r} is never consumed"
# --------------------------------------------------------------------------- #
# #78/#79 -- update notice: when to show it, and what it says
# --------------------------------------------------------------------------- #
def test_update_notice_when_a_newer_release_exists():
n = c.update_notice("1.2.0", {"version": "v1.3.0", "url": "https://example.test/rel"})
assert n is not None
assert n["version"] == "v1.3.0"
assert n["url"] == "https://example.test/rel"
assert "1.3.0" in n["text"] and "1.2.0" in n["text"]
def test_update_notice_is_silent_when_current():
assert c.update_notice("1.3.0", {"version": "v1.3.0"}) is None
assert c.update_notice("1.4.0", {"version": "v1.3.0"}) is None
@pytest.mark.parametrize("bad", [None, {}, {"version": ""}, {"version": None}, {"version": 3}, []])
def test_update_notice_is_silent_on_a_failed_or_malformed_check(bad):
"""fetch_latest_release returns None on any failure; a half-formed payload
must not produce a notice pointing at nothing."""
assert c.update_notice("1.0.0", bad) is None
def test_update_notice_falls_back_to_the_releases_page_without_a_url():
n = c.update_notice("1.0.0", {"version": "v2.0.0"})
assert n["url"] == c.RELEASES_URL
def test_update_notice_names_no_menu_path():
"""The old status-line text said 'Help > About to view it', which is wrong
on macOS -- Qt moves the About action into the application menu (#79). The
notice carries its own action, so it must not describe a menu path."""
n = c.update_notice("1.0.0", {"version": "v2.0.0"})
lowered = n["text"].lower()
for phrase in ("help", "about", "menu", "", ">"):
assert phrase not in lowered, f"notice text should not reference {phrase!r}"
def test_update_notice_handles_the_v_prefix_consistently():
assert c.update_notice("1.2.0", {"version": "1.3.0"}) is not None
assert c.update_notice("v1.2.0", {"version": "v1.3.0"}) is not None
assert c.update_notice("1.3.0", {"version": "v1.3.0"}) is None
def test_update_notice_renders_both_versions_the_same_way():
"""Tags carry a 'v' prefix, __version__ doesn't -- don't show both forms
in one sentence."""
n = c.update_notice("1.2.0", {"version": "v1.3.0"})
assert "v1.3.0" not in n["text"]
assert "1.3.0" in n["text"] and "1.2.0" in n["text"]
# the machine-readable field keeps the real tag
assert n["version"] == "v1.3.0"
# --------------------------------------------------------------------------- #
# #76 -- ${VAR} references. Semantics mirror Claude Code's documented
# behaviour: ${VAR} and ${VAR:-default}, expanded in command/args/env/url/
# headers, and an unset variable with no default left as literal text.
# --------------------------------------------------------------------------- #
def test_find_env_refs_plain_and_defaulted():
refs = c.find_env_refs("${A} and ${B:-fallback}")
assert [(r.name, r.default) for r in refs] == [("A", None), ("B", "fallback")]
@pytest.mark.parametrize("text", ["${}", "${1BAD}", "$NOTBRACED", "{NOPE}", "plain", "$${X"])
def test_find_env_refs_ignores_non_references(text):
assert c.find_env_refs(text) == []
def test_find_env_refs_allows_an_empty_default():
"""`${VAR:-}` is a documented way to say 'blank if unset'."""
refs = c.find_env_refs("${A:-}")
assert refs[0].default == ""
assert refs[0].has_default is True
def test_server_env_refs_covers_all_five_documented_fields():
data = {
"command": "${BIN}",
"args": ["--x", "${ARG}"],
"env": {"K": "${ENVV}"},
"url": "${URL}/mcp",
"headers": {"Authorization": "Bearer ${HDR}"},
}
found = {(r.name, r.field) for r in c.server_env_refs(data)}
assert found == {
("BIN", "command"),
("ARG", "args"),
("ENVV", "env"),
("URL", "url"),
("HDR", "headers"),
}
def test_server_env_refs_ignores_unexpanded_fields():
"""Claude Code expands five fields; a ${VAR} elsewhere isn't a reference."""
assert c.server_env_refs({"description": "${NOPE}", "timeout": "${ALSO_NO}"}) == []
def test_expand_env_refs_matches_documented_semantics():
env = {"SET": "value"}
assert c.expand_env_refs("${SET}", env) == "value"
assert c.expand_env_refs("${MISSING:-dflt}", env) == "dflt"
assert c.expand_env_refs("${SET:-dflt}", env) == "value"
# unset with no default: left as literal text, exactly as Claude Code does
assert c.expand_env_refs("${MISSING}", env) == "${MISSING}"
def test_expand_env_refs_handles_several_in_one_string():
assert c.expand_env_refs("${A}/${B:-two}/${C}", {"A": "one"}) == "one/two/${C}"
def test_unresolved_env_refs_only_flags_unset_without_default():
data = {"env": {"A": "${SET}", "B": "${UNSET}", "C": "${OTHER:-has_default}"}}
assert [r.name for r in c.unresolved_env_refs(data, {"SET": "x"})] == ["UNSET"]
# --- the two interactions that were backwards for this feature ------------
def test_placeholder_under_a_secret_key_is_not_masked():
"""A ${VAR} names a secret rather than being one. Masking it would make a
reference indistinguishable from a stored credential."""
assert c.should_mask_value("API_KEY", "${API_KEY}") is False
assert c.should_mask_value("API_KEY", "ghp_realsecret") is True
assert c.should_mask_value("NOT_SECRET", "${API_KEY}") is False
def test_redacted_display_keeps_placeholders_but_masks_real_secrets():
out = c._redact_server_data({"env": {"API_KEY": "${API_KEY}", "TOKEN": "ghp_real"}})
assert out["env"]["API_KEY"] == "${API_KEY}"
assert out["env"]["TOKEN"] == c.MASK
def test_redact_args_keeps_placeholders_visible():
assert c.redact_args(["--token", "${GH_TOKEN}"]) == ["--token", "${GH_TOKEN}"]
assert c.redact_args(["--api-key=${K}"]) == ["--api-key=${K}"]
# real secrets still masked
assert c.redact_args(["--token", "ghp_real"]) == ["--token", c.MASK]
assert c.redact_args(["--api-key=sk-real"]) == [f"--api-key={c.MASK}"]
def test_args_secret_warning_is_silenced_by_a_placeholder():
"""Moving a token into ${VAR} is the recommended fix for this warning --
still warning afterwards would punish the fix."""
assert c.args_secret_warning({"args": ["--token", "ghp_real"]}) is not None
assert c.args_secret_warning({"args": ["--token", "${GH_TOKEN}"]}) is None
def test_args_secret_warning_still_fires_on_the_arg_after_a_placeholder():
"""A placeholder must clear the pending-flag state, not blanket-suppress."""
assert c.args_secret_warning({"args": ["${SAFE}", "--token", "ghp_real"]}) is not None
# --- per-client gating ----------------------------------------------------
def _profile(path):
return c.Profile(label="p", path=Path(path), config_exists=True)
def test_claude_code_profiles_expand_references():
assert c.client_expands_env_refs(_profile(Path.home() / ".claude.json")) is True
assert c.client_expands_env_refs(_profile("/repo/.mcp.json")) is True
def test_claude_desktop_profile_does_not_expand_references():
desktop = _profile(c.app_support_base() / "Claude" / c.CONFIG_FILENAME)
assert c.client_expands_env_refs(desktop) is False
def test_desktop_profile_warns_that_references_are_literal():
data = {"env": {"API_KEY": "${API_KEY}"}}
desktop = _profile(c.app_support_base() / "Claude" / c.CONFIG_FILENAME)
warnings = c.env_ref_warnings(data, desktop, {"API_KEY": "set"})
assert len(warnings) == 1
assert "NOT be expanded" in warnings[0]
assert "${API_KEY}" in warnings[0]
def test_claude_code_profile_warns_only_about_unset_variables():
code = _profile(Path.home() / ".claude.json")
data = {"env": {"A": "${UNSET_ONE}"}}
assert c.env_ref_warnings(data, code, {}) != []
assert c.env_ref_warnings(data, code, {"UNSET_ONE": "x"}) == []
# a default means it always resolves
assert c.env_ref_warnings({"env": {"A": "${X:-d}"}}, code, {}) == []
def test_no_references_means_no_warnings():
assert c.env_ref_warnings({"command": "npx", "args": ["-y", "pkg"]}, None) == []