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
Fixes findings 2, 3, 4, 6, 7 from the issue #68 adversarial review.
- Finding 2: config.env was type-checked only. Add CATALOG_DENIED_ENV_KEYS
(case-insensitive) for interpreter/loader-override keys (NODE_OPTIONS,
PYTHONPATH, LD_PRELOAD, ...), apply the ASCII check and the existing
secret-value check to env keys/values, and require env values to be
empty or a single <PLACEHOLDER> token.
- Finding 3: version pinning was only checked by catalog_review.py (which
never runs on the signing path per finding 1). Move enforcement into
_validate_catalog_config: npm/uvx specs must carry @version or ==version
(scoped names handled), docker images must have an explicit non-latest
tag. Only the first plausible package-spec token is checked, so flags,
<PLACEHOLDER>s, and docker subcommands/flags don't trip it. All 19 real
catalog entries still validate clean.
- Finding 4: the CI catalog-signature gate imported bcc_core from the PR
branch and trusted whatever CATALOG_PUBKEYS said there, so a PR changing
both catalog.json and CATALOG_PUBKEYS (with a matching signature) went
green. ci.yml now hardcodes the expected base64 pubkey and asserts
bcc_core.CATALOG_PUBKEYS matches it before verifying the signature.
NOTE: the maintainer is planning to rotate this key -- update
EXPECTED_CATALOG_PUBKEY_B64 in ci.yml as its own reviewed change when
that happens, never bundled with a catalog content change.
- Finding 6: resolve_catalog's anti-rollback/anti-freeze guards sat behind
`if best_version >= 0`, so the first verified candidate was accepted
unconditionally and the anti-freeze anchor drifted with each accepted
candidate instead of staying fixed. The cap is now measured against the
bundled catalog's version specifically (the trust anchor baked into the
binary), regardless of evaluation order; bundled wins version ties; and
a new pure `floor` parameter lets a future caller pass a persisted
accepted-version floor.
- Finding 7: catalog id is now constrained to ^[a-z0-9][a-z0-9._-]{0,63}$.
Tests: fixed _minimal_catalog to use a pinned package (was enshrining
finding 3), rewrote the env-passthrough test to prove the validation
boundary instead of asserting env passes through unchecked, and
reordered test_resolve_catalog_rejects_absurd_version_jump so it
actually exercises the first-candidate path. Added positive/negative
tests for every new rule. Manually verified each new check by commenting
it out and confirming the guarding test goes red, then restoring it.
Fixes#68 findings 1 and 5.
Finding 1 -- the review gate signed without reviewing anything:
- ReviewWindow._on_sign hardcoded "main" as the TOCTOU comparison ref, so
any PR review (where _on_load pins the PR head's blob SHA) could never
sign; the only working path was main-vs-itself, whose empty diff made
can_sign() vacuously True (set() <= set()). Commit b08cf21 signed 19
entries through exactly that path with zero of them reviewed.
- can_sign() now refuses an empty changeset outright, and itself checks
has_blocking_risk() across every changed entry rather than trusting the
GUI to have disabled a checkbox.
- ReviewSession now carries loaded_ref (the exact ref reviewed); a new pure
sign_precondition(session, resolve_blob_sha) resolves the TOCTOU SHA from
that ref, never a hardcoded "main". _on_load's retry path re-diffs
instead of re-pinning the same stale SHA, so a blob-mismatch refusal
can't loop forever.
- source="main" now diffs against the last catalog a maintainer actually
SIGNED (walking catalog.json's git history until a version verifies
against the current .sig), not against itself.
- Replaced the theatre-only test_no_acknowledge_all_function_exists (only
asserted no function was *named* acknowledge_all) with a test that also
exercises the real gate. Added can_sign/sign_precondition coverage for
the empty-diff, blocking-risk, and ref-resolution seams -- each verified
to fail when its guard is removed.
Finding 5 -- the catalog key and release key were the same CI-resident key:
- scripts/sign_checksums.py gets its own RELEASE_PUBKEYS (separate from
bcc_core.CATALOG_PUBKEYS) and a verify_checksums_against_any() helper.
- release.yml's signing-smoke-test now verifies RELEASE_SIGNING_KEY against
RELEASE_PUBKEYS only -- it no longer imports bcc_core/CATALOG_PUBKEYS at
all, so this workflow can never compare a CI secret against the
catalog's root of trust.
- catalog_console.py: keygen/show-seed-b64 gain --release, with separate
keychain/file storage per key kind. show-seed-b64 refuses to run without
--release, so the catalog seed can't be exported to a CI secret by habit.
- README documents both keys' trust properties and the asymmetry: a CI
compromise burns the release key, never the catalog key.
The maintainer must rotate the catalog key (it was CI-resident, so treat it
as burned for catalog use) and generate a fresh release key -- see the PR
description for the exact steps. No key is generated or committed here.
Adds a separate PySide6 tool (catalog_console.py) that reviews proposed
changes to data/catalog.json and signs the approved result. Never shipped
to users, never in the release bundle -- maintainer runs it from source.
Pure, GUI-free logic lives in a new catalog_review.py (kept out of both
the GUI and bcc_core.py to avoid merge conflicts on the latter):
- diff_catalogs(old, new) -> list[EntryChange]: semantic (per-entry)
diff, not a text diff, with per-field before/after values.
- Six independent risk predicates, each unit-tested: non-empty
env_required value, command outside bcc_core.CATALOG_ALLOWED_COMMANDS
(imported, not redefined), non-ASCII code points in id/command/args
(rendered with escapes -- homoglyph/RTL-override defence), unpinned
npm/docker package references, URL domain changes (lookalike-domain
swap defence), brand-new entries flagged for extra scrutiny.
- ReviewSession + can_sign(): the Sign button stays disabled until every
changed entry is individually acknowledged -- no "acknowledge all"
shortcut exists, and a comment in the code says never to add one.
- TOCTOU fix (adversarial review on #62): the git blob SHA of
data/catalog.json is pinned when review begins; can_sign() refuses to
sign if the current blob differs, forcing a re-review. The Console
re-fetches the blob SHA immediately before signing and enforces this.
- catalog_signing_message() imports bcc_core's domain-separation prefix
(_CATALOG_SIG_DOMAIN) rather than retyping it, so the Console's
signatures and bcc_core.verify_catalog_signature can't drift apart --
proven by a round-trip test (sign here, verify via bcc_core).
- encrypt_private_key/decrypt_private_key: the signing key is never
stored plaintext (scrypt + AES-256-GCM at rest, OS keychain via the
optional keyring package if available, else an encrypted file under
$HOME outside the repo).
- Registry lookup (lookup_registry_info + injected Fetcher): the network
call is kept out of this module for offline testability;
catalog_console.py supplies npm/PyPI HTTP fetchers. Fails soft --
network down means "unavailable", never a block on review.
near_neighbor_ids() flags edit-distance <=2 typosquat candidates
against existing catalog ids.
catalog_console.py wires the above into a Qt GUI: Load (open PRs
touching data/catalog.json via the Gitea REST API, or main) -> Review
(one EntryCard per changed entry, command/args rendered visually
dominant, risk findings colour-coded, per-card registry-lookup button
running off the UI thread like bcc.py's ConnTester/SpawnTester) -> Sign
(re-checks the pinned blob SHA, prompts for the key passphrase, writes
data/catalog.json + data/catalog.json.sig and commits+pushes BOTH in a
single commit -- so main is never red between a catalog merge and its
signature). Every attacker-controlled string renders through a
plain_label() helper that both escapes HTML and forces Qt.PlainText, so
a script/image payload in a description/notes/URL can't render as
markup. Also provides keygen (generates + stores an encrypted keypair,
prints the base64 public key) and show-seed-b64 (prints the base64
private seed for the RELEASE_SIGNING_KEY CI secret) CLI subcommands.
Excluded from the release bundle: bcc.spec's Analysis() only ever starts
from bcc.py, and tests/test_catalog_console_packaging.py asserts neither
new file is named anywhere in bcc.spec and that bcc.py never imports
either module.
Tests: 67 new (62 in test_catalog_review.py, 5 in
test_catalog_console_packaging.py) covering diff_catalogs, every risk
predicate individually, the acknowledge-gating + TOCTOU can_sign()
logic, the sign/verify round-trip against bcc_core, key encryption
(including wrong-passphrase and corrupted-blob rejection),
edit-distance/near-neighbour matching, and registry-lookup fail-soft
behaviour. Full suite: 321 passed, 1 pre-existing unrelated skip. ruff
check and ruff format --check both clean. catalog_console.py (Qt/GUI)
could not be executed in the sandbox this was developed in (no system
EGL/GL libraries available for PySide6) -- it was syntax-checked
(py_compile) and lint/format-checked but not smoke-tested; see PR body
for what AJ should verify.
Closes#62
Phase 1 of the MCP server catalog: pure, GUI-free core functions plus the
seed data/catalog.json (20 servers). No GUI wiring in this PR -- bcc.py is
untouched; a follow-up PR adds the picker dialog.
- load_catalog(): strict json.loads ONLY. The lenient repair pipeline
(repair_json_text / parse_pasted_json*) is never used on catalog bytes,
by design and by comment, so a signature always authenticates exactly
what gets parsed.
- validate_catalog(): rejects the whole file (not per-entry) on: bad
schema/version types, missing tier-appropriate fields (basic needs
config.command+args, link-only needs docs_url and no config), a
command allowlist (npx/uvx/docker/node/python/python3 only), -e/--eval/-c
denial for node/python, --privileged and root/$HOME volume-mount denial
for docker, non-empty env_required values (hard rejection -- secrets
never ship in the catalog), secret-looking args (reuses
_TOKEN_PREFIXES/_is_secret_value rather than reimplementing), non-https
URL fields, and non-ASCII code points in id/command/args (homoglyph
defence).
- verify_catalog_signature(): Ed25519 via the cryptography package,
domain-separated message (the literal prefix "bcc-catalog-v1|" + raw
bytes), accepts a match against any key in CATALOG_PUBKEYS
(rotation-ready), never raises.
- resolve_catalog(): picks the highest version among bundled/cached/remote
candidates that EACH independently pass verify + validate -- the bundled
catalog gets no implicit trust, closing the hole where an unsigned
payload merged to main would win on being local. Anti-rollback (never
regress below the best verified candidate already in hand) and
anti-freeze (reject a jump of more than 1000 versions) built in.
- catalog_entry_to_paste_json() / config_has_unfilled_placeholders(): small
pure helpers the future GUI dialog will use to feed a catalog pick into
the existing paste-import path and to gate Save on unfilled placeholder
tokens.
data/catalog.json: the provided 20-server seed, with a signed_at field
added at the top level (lives inside the signed payload once real signing
lands in #62). Wired into bcc.spec's PyInstaller datas so it bundles into
the frozen app.
Security requirements from the issue, and where they landed:
- Catalog bytes never touch the lenient JSON repair path -- enforced by
load_catalog()'s strict json.loads and a comment warning against wiring
it in later.
- env_required values are a hard rejection when non-empty, not a warning.
- Secret-looking args are rejected at validation time, reusing the
existing secret-detection helpers instead of duplicating them.
- Non-ASCII id/command/args rejected (typosquat/homoglyph defence).
- URL fields restricted to https://.
- Ed25519 signature verification is domain-separated and never raises.
- The bundled catalog is verified at runtime exactly like remote/cached --
no implicit trust for being local.
- Anti-rollback and anti-freeze bounds on resolve_catalog's version
comparison.
Tests: 42 new tests added to tests/test_core.py (full suite: 239 passed,
1 pre-existing unrelated skip). ruff check and ruff format --check both
clean.
Publish SHA256SUMS for every release archive and sign it with a
detached Ed25519 signature (SHA256SUMS.sig), since paid code signing
(macOS Developer ID, Windows Authenticode) and Sigstore keyless (needs
a Fulcio-trusted OIDC issuer; self-hosted Gitea isn't one) are both
out of budget/scope.
- scripts/sign_checksums.py: dependency-light (cryptography only)
helper to hash a directory of files into a sha256sum(1)-compatible
SHA256SUMS manifest, sign it (domain-separated: b"bcc-release-v1|"
+ raw manifest bytes), and verify a signature. CLI has generate/
sign/verify subcommands; verify doubles as the check path.
- tests/test_checksums.py: 15 unit + CLI-subprocess tests covering
hashing, manifest formatting, sign/verify roundtrip, tamper
detection, wrong-key rejection, domain-separation, and the
no-key-provided failure path (must error, never write an empty/
bogus .sig).
- .github/workflows/release.yml: Publish Release job now checks out
the repo, flattens build artifacts, generates SHA256SUMS, and signs
it from the RELEASE_SIGNING_KEY secret (base64 raw Ed25519 seed) if
present. If the secret is absent, the release still publishes with
a loud ::warning:: and no .sig — it never fails the release or
publishes a bogus signature.
- README.md: new 'Verifying your download' section with the (still
placeholder) public key, sha256sum -c / Get-FileHash commands, and
an explicit statement that this does not remove Gatekeeper/
SmartScreen warnings.
- requirements-dev.txt / ci.yml: add cryptography as a dev/test
dependency for the new script and its tests.
Touches no files from bcc_core.py / tests/test_core.py /
pyproject.toml / bcc.spec to avoid colliding with concurrent work on
those files.
Sets live in the config under _bccServerSets (bcc-owned, ignored by
Claude, travels with the file). Apply enables exactly the set's members
and parks the rest; vanished members are reported, not fatal. GUI row:
set combo + Apply + Save set… + delete.
Closes#52
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Popen.kill() only terminated the direct child, so runner-style commands
(npx -> node -> server) leaked the real server process on every Windows
spawn test. taskkill /PID <pid> /T /F walks the descendant tree. Also
sets CREATE_NO_WINDOW on the spawned test process so the windowed exe
doesn't flash a console per test.
Closes#13
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
python3 is not a command on a stock Windows install; caught by the new
windows-latest CI job. Probe 'python' there and accept warn (found on
augmented PATH) as proof of resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Linux (and any non-desktop platform): refuse instead of 'pkill claude',
which substring-matched running Claude Code CLI sessions and relaunched
the CLI, not a desktop app. New restart_supported() gates the button.
- macOS: wait (<=5s) for the old instance to exit before 'open -a Claude'
so the relaunch can't re-activate the dying process. Runs off the UI
thread via a RestartWorker.
- Windows: verify the Start-menu shortcut exists BEFORE taskkill, so an
MSIX/Store install is never killed without a relaunch path.
Closes#33
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All network calls are monkeypatched (urllib.request.urlopen) — no live
network in tests. Covers older/newer/equal comparisons, v-prefix,
differing-length tuples, malformed input on both sides, and
fetch_latest_release success/timeout/network-failure/malformed-response
paths.
Mocks subprocess.run/Popen and patches sys.platform per-OS branch (darwin,
win32, linux) -- never actually kills or launches anything. Covers: correct
command sequence per platform, pkill/taskkill exiting non-zero (nothing to
kill) is NOT treated as failure, a failed relaunch IS reported as failure,
and profile_targets_claude_desktop() correctly distinguishes Claude Desktop
configs from Claude Code / legacy settings.json profiles.
Includes the required regression case: rewrite a file with different
content, force the original mtime back via os.utime, and assert the
fingerprint still differs (because size changed).
The crashed/exited/stderr spawn tests used 0.3-0.5s timeouts. On a slow CI
runner, interpreter startup can exceed that, so the process is still starting
when the timeout fires, gets killed, and is misclassified "ok" (still running)
instead of "crashed"/"exited". Bump those three to 2.0s. The two "ok" paths
(sleep-60, stdin-block) stay at 0.3s since timing out there means success.
Adds `args_secret_warning(data)` to bcc_core — returns a warning string
when any arg positional value looks like a raw credential (token prefix,
value following a secret-named flag, or URL with embedded user:pass like
postgres://user:pass@host). `--flag=value` inline forms are intentionally
skipped (the flag name already labels the value).
Adds `secret_warn` QLabel in ServerEditor's stdio page; shown/hidden by
`_check_args()` on every field change, and cleared on deselect or
stdio→remote type switch. Non-blocking — save path is not touched.
Closes#1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Records the file mtime at load time; before write_config() fires, re-checks
it. If it changed (e.g. `claude mcp add`, a second BCC window, or Claude
itself writing ~/.claude.json), StaleDialog prompts with the changed top-level
key names and a masked server-section diff. "Merge & save" applies the user's
in-memory server edits on top of the current on-disk file (preserving external
non-server changes); "Overwrite anyway" proceeds as before.
- bcc_core: config_mtime(), external_change_summary(), _server_sections()
helper extracted from backup_diff for reuse
- bcc.py: StaleDialog, MainWindow._loaded_mtime tracked through load/save
- tests: 7 new tests (config_mtime, external_change_summary variants, AC merge test)
Closes#4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The diff preview in RestoreDialog was serializing the full config dict,
exposing secret env values and token args in cleartext on a pasteable surface.
- Add _redact_server_data / _redact_servers_block helpers that apply
redact_args to args and mask env values for is_secret_key() keys
- Rewrite backup_diff to compare only {mcpServers, _disabledMcpServers}
sections (sanitized), not the whole file — also avoids double-serializing
multi-MB ~/.claude.json for a servers-only diff
- Add clarifying comment in _restore_from_backup about why full_config
is the right base after confirm-discard
- Add test: backup_diff with secret args/env → MASK in output, raw values absent
- Add test: restore_backup restores _disabledMcpServers correctly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add list_backups / backup_label / backup_diff / restore_backup to bcc_core,
RestoreDialog to bcc.py, and a "Restore…" button in the profile top bar.
Restore is selective: only mcpServers and _disabledMcpServers are replaced;
all other keys in the config (history, project state) are preserved verbatim.
Goes through write_config() so a pre-restore backup is always created first.
Closes#3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs caught in supervisor review:
1. Stderr was silently dropped when the Details panel was closed during a
crash. _on_spawn_done appended to diag_text only when the panel was
already open, and refresh_dependency() clobbered that text on the next
field change anyway.
Fix: stash the result in self._last_spawn; _full_diag_text() appends
the stderr section whenever diag text is generated; _on_spawn_done
auto-opens the panel on non-ok outcomes (same pattern as the existing
auto_open for missing commands).
2. _drain stopped reading once _STDERR_CAP (4 KB) was reached. A process
that writes more than 4 KB then blocked on a full pipe buffer, never
exited, and was misclassified as "ok" instead of "crashed".
Fix: drain to EOF unconditionally; keep only the first _STDERR_CAP bytes.
Regression test: 64 KB stderr + exit(3) → outcome "crashed".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add spawn_test() to bcc_core — spawns a stdio server for up to 3 s,
captures stderr, and reports ok/exited/crashed/not_found. Key design
decisions driven by real MCP server behaviour:
- stdin=PIPE (never written): servers block on JSON-RPC input and stay
alive, so "still running after timeout" reliably signals a healthy
start. stdin=DEVNULL would send EOF, causing well-behaved servers to
exit 0 and be misclassified as "exited".
- Command resolved via shutil.which(augmented_path()) before Popen so
subprocess PATH resolution is unambiguous across platforms.
- start_new_session=True on POSIX + os.killpg on timeout: kills the
whole process group, not just the launcher (npx, uvx), which would
otherwise orphan the actual node/python grandchild process.
- stdout=DEVNULL: draining a PIPE we don't read would deadlock at ~64 KB.
- stderr drained in a daemon thread, capped at 4 KB.
GUI: SpawnTester(QThread) wraps spawn_test; "Test launch" button in
ServerEditor dep row (stdio only, visible when command resolves ok/warn).
Result colours match the existing dep-status palette (green/amber/red).
Stderr appended to the diagnostics panel if it is open.
7 new unit tests cover all outcomes and the stdin-open regression guard.
Ran python bcc.py locally: button appears for stdio servers whose command
resolves, is hidden for remote servers and missing-command servers.
Closes#2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Env/header values whose key looks secret (TOKEN, API_KEY, PASSWORD,
AUTH, ...) render as •••••••• via a display-only delegate; a
'Show secrets' toggle reveals them. Underlying data, editing, and
save are untouched.
- The Add dialog switches the value field to password echo when the
key name looks secret.
- Copy-diagnostics now redacts secrets from args (--token <v>,
--api-key=<v>, and well-known token prefixes like ghp_/sk-/xoxb-),
since those reports get pasted into public bug reports. Env and
header values were already omitted from diagnostics.
Claude Code stores user-scope MCP servers in ~/.claude.json (what
'claude mcp add' writes); ~/.claude/settings.json is for permissions
and hooks and rejects an mcpServers key with a schema error, so BCC
was reading (and writing) servers where Claude Code never looks.
If servers are found parked in settings.json, that file is still
listed as 'Claude Code (legacy settings.json)' so they can be copied
into the real config via Copy to. Docs updated; verified against
docs.claude.com/en/docs/claude-code/settings.
- Label now explains the model with an example: a flag and its value go
on separate lines. Placeholder shows the common uv pattern.
- split_suspicious_args() flags lines that contain whitespace plus a
dash-prefixed token ('--directory /path') — legit single args with
spaces ('My Documents') are never touched. Quotes are respected.
- The editor shows a warning under the args box with a 'Fix: split onto
separate lines' button; the fix goes through the undo stack.
If a config file on disk fails strict JSON parsing, BCC now runs it
through the same repair pipeline as pasted snippets and shows a dialog
listing the parse error, each fix it would apply, and a preview of the
resulting file. The user chooses: Repair & load (marks the profile
dirty; the file is only rewritten on Save, after the broken original
is backed up) or Cancel. Unsalvageable files keep the old error path.
repair_config_file() in bcc_core never writes to disk itself.
Pasted config snippets no longer have to be valid JSON. repair_json_text()
auto-fixes markdown fences, surrounding prose, // /* */ # comments,
trailing and missing commas, smart quotes, single quotes, unquoted keys,
Python/JS literals, and unclosed braces. parse_pasted_json_verbose()
reports every repair applied; the paste dialog now parses as you type
and previews exactly what will be added and what was fixed.
Also includes ruff lint fixes and formatting across bcc.py/bcc_core.py.