133 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
the_og cd2ac2f6f8 Merge PR #69: make the review gate load-bearing, split the keys (#68)
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / windows-latest) (push) Successful in 24s
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 11s
CI / Catalog signature (push) Successful in 7s
Finding 1: can_sign() returned True on an empty changeset, and _on_sign
compared the reviewed blob against a hardcoded "main" rather than the ref
actually reviewed — so the PR path could never sign, and the main-vs-main
path unlocked Sign with zero entries acknowledged. That is how commit b08cf21
signed 19 entries nobody reviewed. can_sign now refuses an empty diff, checks
has_blocking_risk itself instead of trusting a GUI checkbox, and resolves the
TOCTOU pin from the reviewed ref via a pure, testable sign_precondition().

Finding 5: the catalog key and the release key are now separate. The release
key lives in CI and signs checksums; the catalog key stays offline and signs
what users execute. A CI compromise gets the former, not the latter.
2026-07-12 21:30:13 -04:00
the_og 26c66b7db1 Merge branch 'main' into fix/68-console-gate
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 24s
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
2026-07-12 21:28:27 -04:00
the_og 86139100eb Merge PR #70: enforce the checks we said we had (#68)
CI / Tests (py3.12 / windows-latest) (push) Successful in 23s
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 10s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
CI / Catalog signature (push) Successful in 6s
Findings 2/3/4/6/7. config.env now gets the deny-list, ASCII, secret and
empty-or-placeholder checks that args always had; version pinning is enforced
at runtime, not only in the maintainer tool; the CI gate pins the expected
pubkey instead of trusting the one in the PR it is reviewing; resolve_catalog
anchors its cap to the bundled version and prefers bundled on ties; catalog
ids are constrained.

Tests rewritten: the old fixtures asserted the unpinned form validates clean
and that config.env passes through verbatim — they enshrined two of the bugs.
2026-07-12 21:28:24 -04:00
BCC Agent 38f14deeff fix(core): validate config.env, enforce version pinning, fix CI trust anchor and resolve_catalog guards (#68)
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 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 23s
CI / Catalog signature (pull_request) Successful in 7s
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.
2026-07-12 21:27:08 -04:00
BCC Fix Agent 82483e693d fix(catalog-console): close the vacuous review gate; split catalog/release signing keys
CI / Lint (ruff) (pull_request) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 34s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Catalog signature (pull_request) Successful in 6s
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.
2026-07-12 21:25:34 -04:00
the_og 6fce19cc67 ci: gate the catalog signature, smoke-test the release key (#61, #63)
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 10s
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.12 / windows-latest) (push) Successful in 23s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
CI / Catalog signature (push) Successful in 6s
Two gaps closed now that a real key exists.

1. CI 'Catalog signature' job (the #61 gate): every push/PR verifies
   data/catalog.json against data/catalog.json.sig using the public key in
   bcc_core, and runs validate_catalog. The threat model here is not an
   outsider pushing to the repo -- it is merging a friendly-looking PR
   without really reading it. A contributor can change catalog.json but
   cannot produce a matching signature, so a blindly-merged PR now lands as
   a red build within a minute instead of quietly riding into the next
   release. Public-key only; no secret involved.

2. release.yml 'Signing key smoke test' (workflow_dispatch only): the
   Publish job is gated on a tag, so a manual run never exercised signing --
   a wrong or missing RELEASE_SIGNING_KEY would first surface during a real
   release. This signs a throwaway manifest with the secret and verifies it
   against the public key compiled into bcc_core, proving the two halves of
   the keypair actually match. Publishes nothing.
2026-07-12 18:30:30 -04:00
the_og 37b3c8f5d0 catalog: trust the real signing key
CI / Tests (py3.12 / windows-latest) (push) Successful in 23s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 9s
Adds the Ed25519 public key generated by the Catalog Console (#62),
replacing the b"\x00"*32 placeholder, and imports base64 (the key line
referenced it without the import, so bcc_core failed to load at all).

Verified end to end against the signature the Console pushed in b08cf21:
signature verifies, catalog validates clean, resolve_catalog accepts the
bundled copy (19 servers), and a single-byte tamper is rejected.
2026-07-12 18:25:49 -04:00
Cowork Supervisor b08cf2112b chore: sign data/catalog.json (Catalog Console, #62)
CI / Lint (ruff) (push) Successful in 8s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / windows-latest) (push) Successful in 22s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
Payload and detached Ed25519 signature land together so main is never red between a catalog merge and its signature.
2026-07-12 18:22:41 -04:00
the_og 80761a1f17 Merge PR #66: Catalog Console — maintainer review + signing tool (#62)
CI / Tests (py3.12 / windows-latest) (push) Successful in 22s
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 11s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 12s
Maintainer-only PySide6 tool: semantic per-entry diff of data/catalog.json,
blocking risk predicates (non-empty env values, command allowlist, non-ASCII
homoglyphs, unpinned packages, URL domain changes), automatic off-thread
registry lookups (publisher/age/downloads/near-neighbour), acknowledge-gated
signing with a pinned git blob SHA (refuses to sign bytes that changed since
review), and payload+signature emitted in a single commit.

Never shipped to users — excluded from bcc.spec, with a test asserting it.
2026-07-12 18:04:53 -04:00
BCC Agent d6fc6845c4 fix(catalog-console): run registry lookup automatically, not on click (#62)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 22s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
Review feedback: making the registry lookup an on-demand 'Check registry'
button was a deviation from the design intent, not a style choice. The
registry lookup is the one check a human reviewer genuinely cannot do by
eye -- it's what caught firecrawl-mcp's unrelated npm publisher in the
seed data. Gating it behind a button makes it optional, and an optional
check is the one a tired maintainer skips at 11pm -- exactly the failure
mode this tool exists to defend against. The friction belongs on
approval, never on information.

EntryCard now kicks off its registry lookup automatically at construction
time (i.e. as soon as _render_cards() builds the cards for a loaded
review), one RegistryLookupWorker (QThread) per changed entry, all
starting concurrently as the cards are built. Nothing about the lookup's
pure logic changed -- catalog_review.lookup_registry_info was already
fail-soft (RegistryInfo(available=False) on any fetch problem, never an
exception) and can_sign() never depended on registry state, so a dead
registry still cannot gate review or signing.

Renamed the button 'Check registry' -> 'Re-check' and kept it wired to
the same _run_registry_lookup(), for manually retrying a failed/unavailable
lookup. Label copy now reads loading... while a lookup is in flight (was
'not checked yet.' / 'checking...'), matching the loading -> result |
unavailable per-entry states.

No change to acknowledge-gating or the TOCTOU blob-SHA pin in
catalog_review.py. ruff check/format clean; full suite still 321 passed,
1 pre-existing unrelated skip -- catalog_review.py (the tested pure-logic
module) is untouched, only catalog_console.py's GUI wiring moved from
button-triggered to auto-triggered.
2026-07-12 18:04:28 -04:00
BCC Agent f0d0ab7a08 feat: Catalog Console -- maintainer-only review + signing tool (#62)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 23s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
Adds a separate PySide6 tool (catalog_console.py) that reviews proposed
changes to data/catalog.json and signs the approved result. Never shipped
to users, never in the release bundle -- maintainer runs it from source.

Pure, GUI-free logic lives in a new catalog_review.py (kept out of both
the GUI and bcc_core.py to avoid merge conflicts on the latter):

- diff_catalogs(old, new) -> list[EntryChange]: semantic (per-entry)
  diff, not a text diff, with per-field before/after values.
- Six independent risk predicates, each unit-tested: non-empty
  env_required value, command outside bcc_core.CATALOG_ALLOWED_COMMANDS
  (imported, not redefined), non-ASCII code points in id/command/args
  (rendered with escapes -- homoglyph/RTL-override defence), unpinned
  npm/docker package references, URL domain changes (lookalike-domain
  swap defence), brand-new entries flagged for extra scrutiny.
- ReviewSession + can_sign(): the Sign button stays disabled until every
  changed entry is individually acknowledged -- no "acknowledge all"
  shortcut exists, and a comment in the code says never to add one.
- TOCTOU fix (adversarial review on #62): the git blob SHA of
  data/catalog.json is pinned when review begins; can_sign() refuses to
  sign if the current blob differs, forcing a re-review. The Console
  re-fetches the blob SHA immediately before signing and enforces this.
- catalog_signing_message() imports bcc_core's domain-separation prefix
  (_CATALOG_SIG_DOMAIN) rather than retyping it, so the Console's
  signatures and bcc_core.verify_catalog_signature can't drift apart --
  proven by a round-trip test (sign here, verify via bcc_core).
- encrypt_private_key/decrypt_private_key: the signing key is never
  stored plaintext (scrypt + AES-256-GCM at rest, OS keychain via the
  optional keyring package if available, else an encrypted file under
  $HOME outside the repo).
- Registry lookup (lookup_registry_info + injected Fetcher): the network
  call is kept out of this module for offline testability;
  catalog_console.py supplies npm/PyPI HTTP fetchers. Fails soft --
  network down means "unavailable", never a block on review.
  near_neighbor_ids() flags edit-distance <=2 typosquat candidates
  against existing catalog ids.

catalog_console.py wires the above into a Qt GUI: Load (open PRs
touching data/catalog.json via the Gitea REST API, or main) -> Review
(one EntryCard per changed entry, command/args rendered visually
dominant, risk findings colour-coded, per-card registry-lookup button
running off the UI thread like bcc.py's ConnTester/SpawnTester) -> Sign
(re-checks the pinned blob SHA, prompts for the key passphrase, writes
data/catalog.json + data/catalog.json.sig and commits+pushes BOTH in a
single commit -- so main is never red between a catalog merge and its
signature). Every attacker-controlled string renders through a
plain_label() helper that both escapes HTML and forces Qt.PlainText, so
a script/image payload in a description/notes/URL can't render as
markup. Also provides keygen (generates + stores an encrypted keypair,
prints the base64 public key) and show-seed-b64 (prints the base64
private seed for the RELEASE_SIGNING_KEY CI secret) CLI subcommands.

Excluded from the release bundle: bcc.spec's Analysis() only ever starts
from bcc.py, and tests/test_catalog_console_packaging.py asserts neither
new file is named anywhere in bcc.spec and that bcc.py never imports
either module.

Tests: 67 new (62 in test_catalog_review.py, 5 in
test_catalog_console_packaging.py) covering diff_catalogs, every risk
predicate individually, the acknowledge-gating + TOCTOU can_sign()
logic, the sign/verify round-trip against bcc_core, key encryption
(including wrong-passphrase and corrupted-blob rejection),
edit-distance/near-neighbour matching, and registry-lookup fail-soft
behaviour. Full suite: 321 passed, 1 pre-existing unrelated skip. ruff
check and ruff format --check both clean. catalog_console.py (Qt/GUI)
could not be executed in the sandbox this was developed in (no system
EGL/GL libraries available for PySide6) -- it was syntax-checked
(py_compile) and lint/format-checked but not smoke-tested; see PR body
for what AJ should verify.

Closes #62
2026-07-12 17:57:00 -04:00
the_og 3841106630 Merge pull request 'feat: MCP server catalog core -- signed, validated, resolvable (#10, #61)' (#65) from feat/10-catalog-core into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.12 / windows-latest) (push) Successful in 22s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
2026-07-12 17:55:49 -04:00
the_og e3581b6e8b Merge branch 'main' into feat/10-catalog-core
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.12 / windows-latest) (pull_request) Successful in 43s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
2026-07-12 17:42:12 -04:00
the_og 672d78f903 Merge PR #64: signed SHA256SUMS for releases (#63)
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.12 / windows-latest) (push) Successful in 41s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 11s
Publish SHA-256 checksums for every release artifact and sign them with a
domain-separated Ed25519 signature. Skips signing (loudly) if the key secret
is absent rather than failing the release. README documents verification and
states the limit plainly: this proves the file is the one we published; it
does not remove Gatekeeper/SmartScreen warnings.
2026-07-12 17:42:09 -04:00
the_og 88e93edc6c catalog: pin exact package versions, drop firecrawl, add last_release
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 16s
CI / Lint (ruff) (pull_request) Successful in 52s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 14s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 14s
- Pin every basic-tier entry to an exact published version (npm @x.y.z,
  uvx @x.y.z, docker :tag). Unpinned npx -y <pkg> means a package
  compromised AFTER we ship auto-upgrades into every user; a pin bounds
  supply-chain compromise to versions we actually reviewed.
- Drop firecrawl from the seed (19 entries). npm publish rights are held
  solely by hello_sideguide/sideguide.dev, which has no visible
  relationship to firecrawl.dev, while the package is presented as
  official. Publisher identity we cannot tie to the vendor is exactly
  what this catalog must not execute on a user's machine. Retained in the
  research pool pending confirmation.
- postgres: ship --access-mode=restricted, not unrestricted. A curated
  catalog must not default to handing an LLM write access to your DB.
- Add last_release (ISO date, from the live registry) so the UI can show
  freshness; postgres-mcp and mcp-obsidian are both ~14mo stale.
2026-07-12 17:35:48 -04:00
Cowork Agent 48904c7787 feat: MCP server catalog core -- signed, validated, resolvable (#10, #61)
CI / Lint (ruff) (pull_request) Successful in 12s
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 17s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 14s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 39s
Phase 1 of the MCP server catalog: pure, GUI-free core functions plus the
seed data/catalog.json (20 servers). No GUI wiring in this PR -- bcc.py is
untouched; a follow-up PR adds the picker dialog.

- load_catalog(): strict json.loads ONLY. The lenient repair pipeline
  (repair_json_text / parse_pasted_json*) is never used on catalog bytes,
  by design and by comment, so a signature always authenticates exactly
  what gets parsed.
- validate_catalog(): rejects the whole file (not per-entry) on: bad
  schema/version types, missing tier-appropriate fields (basic needs
  config.command+args, link-only needs docs_url and no config), a
  command allowlist (npx/uvx/docker/node/python/python3 only), -e/--eval/-c
  denial for node/python, --privileged and root/$HOME volume-mount denial
  for docker, non-empty env_required values (hard rejection -- secrets
  never ship in the catalog), secret-looking args (reuses
  _TOKEN_PREFIXES/_is_secret_value rather than reimplementing), non-https
  URL fields, and non-ASCII code points in id/command/args (homoglyph
  defence).
- verify_catalog_signature(): Ed25519 via the cryptography package,
  domain-separated message (the literal prefix "bcc-catalog-v1|" + raw
  bytes), accepts a match against any key in CATALOG_PUBKEYS
  (rotation-ready), never raises.
- resolve_catalog(): picks the highest version among bundled/cached/remote
  candidates that EACH independently pass verify + validate -- the bundled
  catalog gets no implicit trust, closing the hole where an unsigned
  payload merged to main would win on being local. Anti-rollback (never
  regress below the best verified candidate already in hand) and
  anti-freeze (reject a jump of more than 1000 versions) built in.
- catalog_entry_to_paste_json() / config_has_unfilled_placeholders(): small
  pure helpers the future GUI dialog will use to feed a catalog pick into
  the existing paste-import path and to gate Save on unfilled placeholder
  tokens.

data/catalog.json: the provided 20-server seed, with a signed_at field
added at the top level (lives inside the signed payload once real signing
lands in #62). Wired into bcc.spec's PyInstaller datas so it bundles into
the frozen app.

Security requirements from the issue, and where they landed:
- Catalog bytes never touch the lenient JSON repair path -- enforced by
  load_catalog()'s strict json.loads and a comment warning against wiring
  it in later.
- env_required values are a hard rejection when non-empty, not a warning.
- Secret-looking args are rejected at validation time, reusing the
  existing secret-detection helpers instead of duplicating them.
- Non-ASCII id/command/args rejected (typosquat/homoglyph defence).
- URL fields restricted to https://.
- Ed25519 signature verification is domain-separated and never raises.
- The bundled catalog is verified at runtime exactly like remote/cached --
  no implicit trust for being local.
- Anti-rollback and anti-freeze bounds on resolve_catalog's version
  comparison.

Tests: 42 new tests added to tests/test_core.py (full suite: 239 passed,
1 pre-existing unrelated skip). ruff check and ruff format --check both
clean.
2026-07-12 17:32:50 -04:00
BCC Agent cd38fd0c78 Sign release checksums with Ed25519 (#63)
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 23s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 13s
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.
2026-07-12 17:30:09 -04:00
the_og e6b60e94e7 Merge pull request 'release: v1.3.0 — named server sets, project config discovery, schema lint, UX polish' (#60) from release/v1.3.0 into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 9s
Build & Release / Build (Windows) (push) Successful in 53s
Build & Release / Build (Linux) (push) Successful in 1m0s
Build & Release / Build (macOS) (push) Successful in 1m58s
Build & Release / Publish Release (push) Successful in 10s
2026-07-12 14:03:50 -04:00
Cowork Supervisor 4afe21666d release: bump version to 1.3.0
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 21s
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 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 9s
Named server sets (#52), project .mcp.json discovery (#53), schema
lint (#54), Ctrl+S + enable/disable-all (#55).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:02:38 -04:00
the_og 06e74d4d2c Merge pull request 'feat: Ctrl+S save shortcut + enable/disable-all actions (#55)' (#59) from feat/55-ux-polish into main
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
2026-07-12 14:02:23 -04:00
Cowork Supervisor f92b851127 Merge remote-tracking branch 'origin/main' into feat/55-ux-polish
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 9s
2026-07-12 14:00:56 -04:00
the_og 47c95ac006 Merge pull request 'feat: named server sets — save/apply the Active/Disabled split (#52)' (#56) from feat/52-server-sets into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 9s
2026-07-12 14:00:19 -04:00
the_og 6b22ad26f0 Merge pull request 'feat: structural schema lint for server definitions (#54)' (#58) from feat/54-schema-lint into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
2026-07-12 14:00:12 -04:00
the_og 874948506c Merge pull request 'feat: discover Claude Code project .mcp.json configs as profiles (#53)' (#57) from feat/53-project-mcp-discovery into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 8s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
2026-07-12 13:58:52 -04:00
Cowork Supervisor ac2e73e9d7 feat: named server sets — save/apply the Active/Disabled split (#52)
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 21s
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
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>
2026-07-12 13:55:36 -04:00
Cowork Supervisor 82ff149373 feat: structural schema lint for server definitions (#54)
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 19s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 8s
Closes #54

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:55:22 -04:00
Cowork Supervisor 31ef4a0e85 feat: Ctrl+S save shortcut + enable/disable-all actions (#55)
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 20s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 8s
Closes #55

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:55:19 -04:00
Cowork Supervisor 520b1b2ffd feat: discover Claude Code project .mcp.json configs as profiles (#53)
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 20s
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 9s
Closes #53

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:53:38 -04:00
the_og 9f535fb77f Merge pull request 'release: v1.2.1 — first shipped build with icon fix, audit fixes #32–#40, Windows tree-kill (#13)' (#51) from release/v1.2.1 into main
CI / Lint (ruff) (push) Successful in 9s
Build & Release / Build (Linux) (push) Successful in 1m0s
Build & Release / Build (Windows) (push) Successful in 58s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 8s
Build & Release / Build (macOS) (push) Successful in 1m43s
Build & Release / Publish Release (push) Successful in 9s
2026-07-12 13:33:26 -04:00
Cowork Supervisor 8cf19d43c4 test: version-sync test reads pyproject.toml instead of hard-coding
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 19s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
CI / Lint (ruff) (pull_request) Successful in 7s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:32:23 -04:00
Cowork Supervisor 0ef4586698 release: bump version to 1.2.1
First release actually shipping the v1.2.0 feature set (the v1.2.0
tag's release run was cancelled and produced no assets) plus the
audit fixes #32-#40 and the Windows process-tree kill (#13).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:31:36 -04:00
the_og ed7c40cac9 Merge pull request 'fix: kill the whole process tree on Windows spawn-test timeout (#13)' (#50) from fix/13-windows-tree-kill into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.12 / windows-latest) (push) Successful in 21s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
2026-07-12 13:31:15 -04:00
Cowork Supervisor 1384ed9703 fix: kill the whole process tree on Windows spawn-test timeout (#13)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
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>
2026-07-12 13:30:02 -04:00
the_og 41891ddad4 Merge pull request 'fix: block duplicate env/header keys at entry instead of silently dropping one (#38)' (#49) from fix/38-duplicate-kv-keys into main
CI / Tests (py3.12 / windows-latest) (push) Successful in 18s
CI / Lint (ruff) (push) Successful in 13s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 13s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 12s
2026-07-12 13:06:54 -04:00
the_og 408f517c5d Merge pull request 'fix: pop old AND new name from health cache on rename (#36)' (#48) from fix/36-health-cache-rename into main
CI / Tests (py3.12 / windows-latest) (push) Successful in 17s
CI / Lint (ruff) (push) Successful in 11s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 14s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 11s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 13s
2026-07-12 13:06:48 -04:00
the_og 9036729cd8 Merge pull request 'fix: MSIX warning gets a persistent banner instead of the status bar (#35)' (#47) from fix/35-msix-banner into main
CI / Tests (py3.12 / windows-latest) (push) Successful in 18s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 14s
CI / Lint (ruff) (push) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 14s
2026-07-12 13:06:41 -04:00
the_og 29a08e9532 Merge pull request 'ci: test on windows + python 3.13 (#40)' (#46) from ci/40-test-matrix into main
CI / Tests (py3.12 / windows-latest) (push) Successful in 18s
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 12s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
2026-07-12 13:06:34 -04:00
Cowork Supervisor 87303809b8 test: dep-check probe command must exist on Windows too
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 17s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
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>
2026-07-12 13:05:04 -04:00
Cowork Supervisor 42456f25d2 fix: block duplicate env/header keys at entry instead of silently dropping one (#38)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 9s
Closes #38

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:04:49 -04:00
Cowork Supervisor 2d274b9e03 fix: MSIX warning gets a persistent banner instead of the status bar (#35)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 10s
CI / Tests (py3.12) (pull_request) Successful in 9s
The status bar is rewritten on every action, so the appended MSIX
warning vanished on first interaction. A dedicated warn banner under
the top bar stays visible.

Closes #35

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:03:57 -04:00
Cowork Supervisor 5c476bb13f fix: pop old AND new name from health cache on rename (#36)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 10s
Closes #36

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:03:29 -04:00
Cowork Supervisor 62c8a2ea65 ci: use host py launcher for Windows tests (runner blocks setup-python)
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 20s
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
The self-hosted Windows runner's PowerShell execution policy rejects
setup-python's install script, so Windows mirrors release.yml: py -3.12
+ venv (the version the release binaries ship with). Linux keeps the
full 3.10/3.12/3.13 setup-python matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:02:09 -04:00
the_og b485357cd5 Merge pull request 'fix: import every dropped .json (aggregate counts) + 5 MB size guard (#39)' (#45) from fix/39-drop-import-all into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 9s
CI / Tests (py3.12) (push) Successful in 9s
2026-07-12 13:00:43 -04:00
the_og 6a91f830dc Merge pull request 'fix: only advance update/lastCheck after a completed check (#37)' (#44) from fix/37-update-lastcheck into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 9s
CI / Tests (py3.12) (push) Successful in 10s
2026-07-12 13:00:36 -04:00
the_og 6dacc26057 Merge pull request 'fix: harden restart_claude_desktop — Linux refusal, macOS quit-wait, Windows MSIX guard (#33)' (#43) from fix/33-restart-hardening into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 9s
CI / Tests (py3.12) (push) Successful in 9s
2026-07-12 13:00:30 -04:00
the_og 1087fc84d1 Merge pull request 'fix: keep UpdateCheckWorker alive until its thread finishes (#32)' (#42) from fix/32-about-worker-lifetime into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
2026-07-12 13:00:22 -04:00
the_og 5df364fb2e Merge pull request 'fix: spawn_test never raises — str-coerce env/command, wrap unexpected errors (#34)' (#41) from fix/34-spawn-test-env into main
CI / Lint (ruff) (push) Successful in 8s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
2026-07-12 13:00:14 -04:00
Cowork Supervisor c7b2c90518 fix: spawn_test never raises — coerce env/command to str, wrap unexpected errors (#34)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
Closes #34

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:59:19 -04:00
Cowork Supervisor c493aa0c84 fix: harden restart_claude_desktop across platforms (#33)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 10s
- 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>
2026-07-12 12:58:15 -04:00
Cowork Supervisor bb355dac31 fix: keep UpdateCheckWorker alive until its thread finishes (#32)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
Closing the About dialog mid-check GC'd the dialog and the running
QThread with it -> 'QThread: Destroyed while thread is still running'.
A class-level keepalive set now holds each worker until finished;
stale results to a destroyed receiver are dropped by Qt.

Closes #32

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:53:12 -04:00
Cowork Supervisor d95db2b026 fix: import every dropped .json (aggregate counts) + 5 MB size guard (#39)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 9s
Closes #39

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:47:30 -04:00
Cowork Supervisor 42963f98b4 ci: test on windows + python 3.13 (#40)
CI / Tests (py3.10 / windows-latest) (pull_request) Failing after 24s
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 14s
CI / Tests (py3.13 / windows-latest) (pull_request) Failing after 10s
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 27s
Closes #40

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:47:14 -04:00
Cowork Supervisor f5c9780948 fix: only advance update/lastCheck after a completed check (#37)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 8s
Closes #37

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:46:59 -04:00
the_og 06326e5e9d fix: app icon in packaged builds (#31, closes #20)
CI / Lint (ruff) (push) Successful in 9s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
2026-07-08 12:00:52 -04:00
Cowork Supervisor 6d91c709a7 fix: bundle icons + set app window icon and Windows AppUserModelID (#20)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 10s
2026-07-08 11:59:57 -04:00
the_og 3b5379a2b8 feat: server search/filter + test-all health column (#30, closes #27 #28)
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
2026-07-08 11:56:37 -04:00
Cowork Supervisor f4d4301c26 Merge remote-tracking branch 'origin/main' into feat/server-list-ux
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-08 11:55:36 -04:00
the_og 5169b7276e feat: MSIX-virtualized Claude config detection (#29, closes #7)
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
2026-07-08 11:55:24 -04:00
Cowork Supervisor 668fb903d0 feat: server search/filter + test-all health column (#27, #28)
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 7s
2026-07-08 11:53:31 -04:00
Cowork Supervisor 8c456c9a89 feat: detect MSIX-virtualized Claude config path + warn (#7)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-08 11:51:23 -04:00
the_og 4c6fe7c5aa release: v1.2.0 (#26) — merge #6/#8/#9/#17/#18/#19
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.10) (push) Successful in 7s
CI / Tests (py3.12) (push) Successful in 8s
Build & Release / Build (Linux) (push) Successful in 59s
Build & Release / Build (Windows) (push) Successful in 1m9s
Build & Release / Build (macOS) (push) Has been cancelled
Build & Release / Publish Release (push) Has been cancelled
2026-07-08 11:34:34 -04:00
Cowork Supervisor 8c718387c0 chore: bump version to 1.2.0 for release
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-07 22:14:49 -04:00
Cowork Supervisor 15a30fb986 Merge branch 'feat/18-19-about-update-checker' into integration/v1.2.0
# Conflicts:
#	bcc.py
#	bcc_core.py
#	tests/test_core.py
2026-07-07 22:13:29 -04:00
Cowork Supervisor c56dec8051 Merge branch 'feat/9-restart-claude-desktop' into integration/v1.2.0
# Conflicts:
#	bcc_core.py
#	tests/test_core.py
2026-07-07 22:12:35 -04:00
Cowork Supervisor 70b865be8f Merge branch 'feat/6-log-viewer' into integration/v1.2.0
# Conflicts:
#	bcc_core.py
#	tests/test_core.py
2026-07-07 22:12:17 -04:00
Cowork Supervisor 8fdcbda681 Merge branch 'feat/17-stale-size-hardening' into integration/v1.2.0 2026-07-07 22:10:59 -04:00
the_og 2d9fb083dc Fix: correct smart-quote/nbsp characters mangled in previous push
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-07 20:56:14 -04:00
the_og 4ab3c3b00a Fix: rebuild from clean main (previous push included unrelated contaminated content)
CI / Lint (ruff) (pull_request) Failing after 8s
CI / Tests (py3.10) (pull_request) Failing after 8s
CI / Tests (py3.12) (pull_request) Failing after 7s
2026-07-07 20:51:26 -04:00
the_og 3e07b51134 Fix: rebuild from clean main (previous push included unrelated contaminated content)
CI / Lint (ruff) (pull_request) Failing after 6s
CI / Tests (py3.10) (pull_request) Successful in 7s
CI / Tests (py3.12) (pull_request) Successful in 7s
2026-07-07 20:48:52 -04:00
the_og a811e323e6 Fix: rebuild from clean main (previous push included unrelated contaminated content)
CI / Lint (ruff) (pull_request) Failing after 6s
CI / Tests (py3.10) (pull_request) Successful in 7s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-07 20:44:39 -04:00
the_og 3cd18392c9 test: cover __version__, parse_version/is_newer_version, and fetch_latest_release (#19)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 7s
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.
2026-07-07 20:44:00 -04:00
the_og 67c898cd35 feat: About dialog with menu bar + notify-only update checker UI (#18, #19)
- Add a real QMenuBar (Help -> About...).
- AboutDialog: app icon, name, version (core.__version__), and links to the
  repo/issues/license opened via QDesktopServices.openUrl (system browser,
  never in-app navigation). macOS unsigned-app note.
- "Check for updates" button + UpdateCheckWorker (QThread) runs
  core.fetch_latest_release() off the UI thread; shows "up to date" or
  "vX.Y.Z available" with a button to open the releases page. No binary
  download, ever.
- Optional quiet startup auto-check, throttled to once/day via a QSettings
  timestamp, off-thread, silent on failure, toggle lives in the About dialog.
2026-07-07 20:42:27 -04:00
the_og 16961a5cc8 test: cover restart_claude_desktop() and profile_targets_claude_desktop() (#9)
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
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.
2026-07-07 20:40:57 -04:00
the_og 2b3843a714 feat: add View logs button + LogViewerDialog for in-app MCP log viewer (#6)
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 7s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-07 20:40:32 -04:00
the_og 256827eaf3 fix: use \xa0 escape instead of a literal NBSP byte (transmission-safe, same behavior); drop stray trailing blank line 2026-07-07 20:38:35 -04:00
the_og 85d47aea97 test: add server_log_path coverage (macOS, Windows, missing, unsupported) (#6) 2026-07-07 20:36:58 -04:00
the_og f9752211a2 test: add coverage for config_fingerprint mtime+size hardening (#17)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 11s
CI / Tests (py3.12) (pull_request) Successful in 7s
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).
2026-07-07 20:35:03 -04:00
the_og 7c8fd6d0bb fix: use explicit backslash-u-0-0-a-0 escape for non-breaking space literal 2026-07-07 20:34:53 -04:00
the_og f1935fe320 feat: use mtime+size fingerprint for stale-file check (#17)
MainWindow._loaded_mtime -> _loaded_stat (core.ConfigStat), populated
via core.config_fingerprint() at load/save time. The save-path stale
check now compares the full fingerprint (mtime AND size) instead of
bare mtime equality.
2026-07-07 20:33:38 -04:00
the_og 0ffc6a1fb6 chore: restore trailing newline in bcc_core.py (attempt 2) 2026-07-07 20:32:36 -04:00
the_og fd2c3567a0 feat: Restart Claude Desktop button after save (#9)
After a successful save, shows a 'Restart Claude Desktop' button next to
the status line. Only shown when the saved profile targets Claude Desktop
(core.profile_targets_claude_desktop) -- never for Claude Code, which has
no GUI process to bounce. Clicking it calls core.restart_claude_desktop()
and reports success/failure. The button hides again on further edits or
when switching/reloading profiles.
2026-07-07 20:32:14 -04:00
the_og 346d0aabb6 fix: use   escape for non-breaking space (byte-exact transcription fix) 2026-07-07 20:30:35 -04:00
the_og 5d59c1c423 fix: restore actual file content (previous commit had placeholder text by mistake) 2026-07-07 20:30:03 -04:00
the_og cffdee8a40 chore: restore trailing newline in bcc_core.py 2026-07-07 20:29:33 -04:00
the_og 0843c51c7d tests/test_core.py: add resolve_name_collision unit tests
CI / Lint (ruff) (pull_request) Failing after 8s
CI / Tests (py3.10) (pull_request) Failing after 8s
CI / Tests (py3.12) (pull_request) Failing after 8s
2026-07-07 20:27:24 -04:00
the_og 82cec27c11 fix: use chr(0xA0) instead of a literal/escaped NBSP
Two prior attempts to fix this line via a literal or  -escaped
non-breaking space both silently reverted back to a no-op regular-space
replace during transcription. Using chr(0xA0) instead removes any
non-ASCII or backslash-escape character from the source line entirely.
2026-07-07 20:27:17 -04:00
the_og 6c51bac1e0 feat: restart_claude_desktop() core logic (#9)
Platform-abstracted restart: macOS uses pkill -x Claude + open -a Claude,
Windows uses taskkill + relaunch via the Start-menu shortcut, Linux uses
pkill claude + a detached Popen relaunch. Not finding a running process is
not an error -- only a failed relaunch is reported as failure.

Also adds profile_targets_claude_desktop() to distinguish Claude Desktop
profiles (claude_desktop_config.json) from Claude Code profiles, used to
gate the restart button in the GUI.
2026-07-07 20:26:53 -04:00
the_og 4b45251682 fix: actually apply the non-breaking-space escape fix
The previous commit message said this was fixed but the content still
had the literal (unescaped) line. Apply the   escape for real
this time.
2026-07-07 20:26:37 -04:00
the_og 2a0802b22f bcc.py: unify paste/drop collision handling via shared _import_server() prompt 2026-07-07 20:26:00 -04:00
the_og fe66d53e9f feat: __version__ constant + notify-only update checker logic (#19), version source for About dialog (#18)
- Add `__version__ = "1.1.0"` as the single source of truth (matches pyproject.toml).
- Add parse_version()/is_newer_version() for numeric (never lexical) version
  comparison, handling v-prefix, pre-release suffixes, and malformed input.
- Add fetch_latest_release(): reads the public Gitea releases API
  (anonymous, no token) and returns {version, url} or None on any failure.
  Never downloads or touches a binary — metadata only.
2026-07-07 20:25:33 -04:00
the_og 8d90ab449d feat: add server_log_path() for in-app MCP log viewer (#6) 2026-07-07 20:24:10 -04:00
the_og 9760b1537e fix: restore non-breaking-space literal lost in transcription
The previous commit accidentally normalized the non-breaking space
(U+00A0) in _normalize_unicode's replace() call to a regular space
during a copy/paste, turning that replace() into a no-op. Use an
explicit   escape instead of the literal character so it can't
be silently corrupted again.
2026-07-07 20:23:44 -04:00
the_og 0f1cdbef3c bcc_core.py: fix non-breaking-space byte lost in transcription 2026-07-07 20:22:45 -04:00
the_og 165c65be5f bcc_core.py: add resolve_name_collision() for paste/import name-collision handling 2026-07-07 20:19:25 -04:00
the_og 3c65657d2f feat: harden stale-file detection with mtime+size fingerprint (#17)
Bare mtime equality can miss a concurrent external write that lands
within the filesystem's mtime resolution (same-second writes), or
where the writer restores the original mtime. Add config_fingerprint()
returning a (mtime, size) ConfigStat pair; the stale-file check in
bcc.py now compares both fields instead of mtime alone. config_mtime()
is kept as-is (still used/tested independently).
2026-07-07 20:17:57 -04:00
the_og 832e5fa048 fix: bump spawn-test timeouts to avoid CI flakiness (closes #12)
Build & Release / Build (macOS) (push) Successful in 1m29s
Build & Release / Build (Linux) (push) Successful in 1m1s
Build & Release / Build (Windows) (push) Successful in 1m12s
Build & Release / Publish Release (push) Successful in 10s
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 10s
CI / Tests (py3.12) (push) Successful in 8s
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.
2026-07-04 13:12:04 -04:00
AJ c6c6dfa5bc docs: update HANDOFF.md — #1r done, next is #8 duplicate-name conflict
CI / Lint (ruff) (push) Successful in 8s
CI / Tests (py3.10) (push) Successful in 9s
CI / Tests (py3.12) (push) Successful in 8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 12:00:44 -04:00
the_og ece049c993 Merge pull request 'feat: secret-in-args warning badge (#1r)' (#16) from feat/1r-secret-args-badge into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
feat: secret-in-args warning badge (#1r) — Closes #1
2026-07-02 12:00:16 -04:00
AJ f4648b3c06 feat: warn when a secret-shaped value is found in args instead of env (#1r)
CI / Lint (ruff) (pull_request) Successful in 8s
CI / Tests (py3.10) (pull_request) Successful in 10s
CI / Tests (py3.12) (pull_request) Successful in 9s
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>
2026-07-02 11:59:17 -04:00
AJ cda7d72e44 docs: update HANDOFF.md — #4 done, next is #1r secret-args badge
CI / Lint (ruff) (push) Successful in 10s
CI / Tests (py3.10) (push) Failing after 2m47s
CI / Tests (py3.12) (push) Successful in 9s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 02:09:35 -04:00
the_og 13d6b917bb Merge pull request 'feat: stale-file protection — detect concurrent edits on save' (#15) from feat/4-stale-file-protection into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
Merge feat/4-stale-file-protection: stale-file protection (closes #4)
2026-07-02 02:08:13 -04:00
AJ 9fa502c500 feat: detect stale config and prompt merge-or-overwrite on save
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 7s
CI / Tests (py3.12) (pull_request) Successful in 8s
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>
2026-07-02 01:57:32 -04:00
the_og be45be9eab Merge pull request 'feat: backup restore UI (#3)' (#14) from feat/3-backup-restore-ui into main
CI / Lint (ruff) (push) Successful in 16s
CI / Tests (py3.10) (push) Successful in 16s
CI / Tests (py3.12) (push) Successful in 15s
feat: backup restore UI (#3) — closes #3
2026-07-02 01:44:44 -04:00
AJ 6ab4789a70 fix: scope backup_diff to server sections and mask secrets
CI / Lint (ruff) (pull_request) Successful in 16s
CI / Tests (py3.10) (pull_request) Successful in 17s
CI / Tests (py3.12) (pull_request) Successful in 14s
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>
2026-07-02 01:40:24 -04:00
AJ df332a06ba feat: backup restore UI (#3)
CI / Lint (ruff) (pull_request) Successful in 10s
CI / Tests (py3.10) (pull_request) Successful in 10s
CI / Tests (py3.12) (pull_request) Successful in 10s
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>
2026-07-02 01:22:52 -04:00
the_og 111fa8e367 Merge pull request 'feat: stdio server spawn-test (#2)' (#11) from feat/2-stdio-spawn-test into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 7s
Merge feat/2-stdio-spawn-test into main
2026-07-02 01:11:48 -04:00
AJ 231403c1d5 fix: spawn-test stderr persistence and drain-cap deadlock (PR #11 review)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 10s
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>
2026-07-02 01:05:34 -04:00
AJ e98cb7abd7 feat: stdio server spawn-test (issue #2)
CI / Lint (ruff) (pull_request) Successful in 8s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 7s
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>
2026-07-02 00:57:46 -04:00
AJ e0eb3c2d1b docs: add HANDOFF.md — backlog workflow for local agent + Gitea supervision
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 7s
CI / Tests (py3.12) (push) Successful in 7s
2026-07-02 00:35:46 -04:00
AJ 6dee318976 feat: mask secret values in the UI and redact them from diagnostics
- 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.
2026-07-02 00:31:34 -04:00
AJ b16c0fd526 fix: point Claude Code profile at ~/.claude.json, not settings.json
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 7s
CI / Tests (py3.12) (push) Successful in 7s
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.
2026-07-02 00:27:01 -04:00
AJ 3c99ff547b fix: drop web-CSS font aliases from the Qt stylesheet
'-apple-system' is a web convention, not a real font family — Qt scans
every installed font trying to resolve it (the 'Populating font family
aliases' warning at startup). Qt already defaults to the native system
UI font on each platform, so don't name UI fonts at all. The diag
panel's 'SF Mono' (not system-installed on macOS) becomes Menlo.
2026-07-02 00:21:33 -04:00
AJ e11886bde9 feat: clearer Arguments editor with typed-together detection and one-click fix
- 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.
2026-07-02 00:13:06 -04:00
AJ 77f469c1dd feat: offer guided repair when loading a broken config file
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.
2026-07-02 00:10:27 -04:00
AJ d6ce4a0fb0 feat: numbered gutter in the Arguments editor
The one-arg-per-line model read as odd text wrapping — a path on its
own line looked like a wrapped continuation of the previous argument.
A line-number gutter makes each argument visibly its own item.
ArgsEdit also owns the NoWrap setting now.
2026-07-02 00:05:26 -04:00
AJ d2f7ce112c fix: legible inline cell editing + no soft-wrap in args box
- Cell editors in env/header tables got the global QLineEdit style
  (6px vertical padding, 7px radius) crammed into a short row, clipping
  the text to an unreadable sliver. Give table editors a compact flat
  style and bump default row height to 34px.
- Editor select-all now uses dim-orange/white selection colors inside
  cells for contrast against the dark field.
- Arguments box no longer soft-wraps: one arg per line means a wrapped
  path looks like two args. Long lines scroll horizontally instead.
2026-07-01 23:58:06 -04:00
AJ a02d2abe49 fix: readable text selection in inputs (selection-color on orange highlight)
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 7s
CI / Tests (py3.12) (push) Successful in 6s
Editing a table cell select-alls its text; without an explicit
selection-color the highlighted text rendered near-invisible against
the accent-orange selection background. Applies to all line edits,
text areas, and combo boxes.
2026-07-01 23:55:18 -04:00
AJ d5c89e9602 feat: user-resizable panes with persisted layout
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 6s
CI / Tests (py3.12) (push) Successful in 6s
- Horizontal splitter between the server list and the editor panel
- Vertical splitter between the Active and Disabled tables (drops the
  fixed 170px cap on the disabled list)
- Editor fields (args, env/header tables) now grow with the window
  instead of being pinned to fixed max heights
- Splitter positions and window geometry persist across launches via
  QSettings; handles highlight on hover/drag
2026-07-01 23:51:40 -04:00
AJ 6205c85b61 feat: lenient JSON repair for pasted MCP snippets
CI / Lint (ruff) (push) Successful in 10s
CI / Tests (py3.10) (push) Successful in 23s
CI / Tests (py3.12) (push) Successful in 10s
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.
2026-07-01 23:43:39 -04:00
AJ 157dad9192 style: apply ruff lint fixes and formatting 2026-07-01 23:43:39 -04:00
AJ 8124150e34 ci: add lint + test workflow (ruff check/format, pytest on py3.10 & 3.12) 2026-07-01 23:43:30 -04:00
AJ 78595be2a2 test: migrate test_core.py script to a proper pytest suite under tests/ 2026-07-01 23:43:30 -04:00
AJ 5ec53b87bc chore: add pyproject.toml, ruff/pytest config, pre-commit hooks, dev deps 2026-07-01 23:43:30 -04:00
AJ Avezzano f5749947e1 fix: write BMP DIB entries in ICO so Windows exe shows correct icon
Pillow's ICO saver stores all sizes as PNG-compressed ("Vista icon"
format).  PyInstaller's Windows resource-updater cannot embed
PNG-compressed entries for small sizes and silently falls back to its
default gear icon.

Fix build_icons.py to write the ICO manually: BMP DIB for sizes ≤ 128 px,
PNG only for the 256 px entry (where Windows Explorer expects PNG).
Regenerate icons/app.ico with the new code.

Also set upx=False in bcc.spec for the Windows/Linux EXE; UPX is another
known cause of icon resources being stripped from PE files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 22:42:02 -04:00
25 changed files with 11934 additions and 348 deletions
+176
View File
@@ -0,0 +1,176 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
lint:
runs-on: ubuntu-latest
name: Lint (ruff)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install ruff
run: pip install ruff
- name: ruff check
run: ruff check .
- name: ruff format --check
run: ruff format --check .
test:
runs-on: ${{ matrix.os }}
name: Tests (py${{ matrix.python }} / ${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
python: ["3.10", "3.12", "3.13"]
include:
# Windows tests on 3.12 only — the version the release binaries ship
# with. The self-hosted Windows runner blocks setup-python's install
# script (PowerShell execution policy), so it uses the host's `py`
# launcher + venv, same as release.yml.
- os: windows-latest
python: "3.12"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python }} (Linux)
if: runner.os == 'Linux'
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Set up Python venv (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
py -${{ matrix.python }} -m venv .venv
Add-Content -Path $env:GITHUB_PATH -Value "$env:GITHUB_WORKSPACE\.venv\Scripts"
# bcc_core has no GUI imports, so the test suite needs no PySide6 —
# keeps CI fast and avoids Qt system-library headaches on the runner.
# cryptography is for tests/test_checksums.py (release signing helper).
- name: Install test dependencies
run: pip install pytest cryptography
- name: Run tests
run: python -m pytest -v
# ── Catalog signature gate (#61) ─────────────────────────────────────────
#
# data/catalog.json is a list of command+args entries that BCC writes into
# the user's Claude config, which Claude then EXECUTES. The catalog is only
# trusted if it carries a valid Ed25519 signature from the maintainer key.
#
# The threat this gate exists for is NOT an outsider pushing to the repo —
# it is the maintainer merging a friendly-looking PR without really reading
# it. A contributor can change catalog.json but cannot produce a matching
# signature, so a blindly-merged PR lands here as a RED BUILD within a
# minute, instead of quietly riding into the next release.
#
# Public-key verification only. No secret is used or needed.
catalog-signature:
name: Catalog signature
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install cryptography
# 🔴 TRUST ANCHOR — issue #68 finding 4.
#
# This step used to do `import bcc_core as c` FROM THE CHECKED-OUT PR
# BRANCH and verify the catalog against c.CATALOG_PUBKEYS — i.e. it
# trusted the public key shipped in the very diff it was reviewing. A
# PR that changed data/catalog.json AND bcc_core.CATALOG_PUBKEYS (to
# an attacker key, with a matching signature produced by the attacker's
# matching private key) went green, because there was nothing outside
# the PR's own content to check the key against. The gate's whole
# point is catching a friendly-looking PR the maintainer merges
# without really reading it — and that hole made it a two-file diff.
#
# EXPECTED_CATALOG_PUBKEY_B64 below is hardcoded HERE, in the workflow
# file, independent of whatever bcc_core.py says on the PR branch. It
# is intentionally the only line in this step that matters for
# security review: changing it changes what this gate is willing to
# trust. THIS CONSTANT IS A TRUST ANCHOR. A PR that changes this line
# in the same diff as a catalog change is exactly the attack this gate
# exists to prevent — review a change to this line on its own,
# never bundled with a catalog update.
#
# NOTE for the next key rotation: update EXPECTED_CATALOG_PUBKEY_B64
# below to the new key's base64 form, as its own reviewed change.
- name: Verify data/catalog.json.sig
env:
EXPECTED_CATALOG_PUBKEY_B64: "082NOwVB7uURkvfyS3+knJ+40Fk6C9unsF47+2uPKo4="
run: |
python - <<'PY'
import base64, os, pathlib, sys
import bcc_core as c
expected_pubkey_b64 = os.environ["EXPECTED_CATALOG_PUBKEY_B64"]
raw = pathlib.Path("data/catalog.json").read_bytes()
sig_path = pathlib.Path("data/catalog.json.sig")
if not sig_path.exists():
sys.exit("FAIL: data/catalog.json.sig is missing. The catalog must be "
"signed via the Catalog Console (#62) before it can land.")
if b"\x00" * 32 in c.CATALOG_PUBKEYS:
sys.exit("FAIL: CATALOG_PUBKEYS still holds the placeholder key.")
# Trust anchor check FIRST, before verifying anything against
# bcc_core.CATALOG_PUBKEYS: a PR is not allowed to bring its own
# key. CATALOG_PUBKEYS on the checked-out branch must be EXACTLY
# the key(s) this workflow file itself expects -- no more, no
# fewer, no substitutions.
actual_pubkeys_b64 = [base64.b64encode(k).decode() for k in c.CATALOG_PUBKEYS]
if actual_pubkeys_b64 != [expected_pubkey_b64]:
sys.exit(
"FAIL: bcc_core.CATALOG_PUBKEYS on this branch does not match the "
"trust anchor hardcoded in .github/workflows/ci.yml.\n"
f" expected: {[expected_pubkey_b64]}\n"
f" actual: {actual_pubkeys_b64}\n"
"\n"
"This PR is changing (or has changed) the catalog signing key. That "
"change must be reviewed on its own, separately from any catalog "
"content change, and the workflow's EXPECTED_CATALOG_PUBKEY_B64 "
"updated deliberately -- not accepted because it happened to match "
"whatever bcc_core.py says on this branch."
)
if not c.verify_catalog_signature(raw, sig_path.read_bytes(), c.CATALOG_PUBKEYS):
sys.exit(
"FAIL: data/catalog.json does NOT match its signature.\n"
"\n"
"The catalog changed without being re-signed. Either someone edited\n"
"it directly (a PR you merged?), or a signing pass was forgotten.\n"
"Re-review and re-sign with the Catalog Console — do not bypass this."
)
problems = c.validate_catalog(c.load_catalog(raw))
if problems:
sys.exit("FAIL: catalog failed validation:\n " + "\n ".join(problems))
print("OK: catalog signature verifies, the pubkey matches the CI trust anchor, "
"and the catalog validates clean.")
PY
+157 -1
View File
@@ -95,6 +95,92 @@ jobs:
name: ${{ matrix.artifact }}
path: ${{ matrix.artifact }}
# ── Signing-key smoke test (workflow_dispatch only) ─────────────────────
#
# The Publish job is gated on a tag, so a manual run never exercises the
# signing step — which means a wrong/missing RELEASE_SIGNING_KEY secret
# would only be discovered at the worst possible moment: during a real
# release. This job signs a throwaway manifest with the secret and verifies
# the result against scripts/sign_checksums.RELEASE_PUBKEYS.
#
# IMPORTANT (issue #68 finding 5): this must verify against the RELEASE
# public key, never bcc_core.CATALOG_PUBKEYS. The catalog key is the
# offline, maintainer-held root of trust for what BCC executes; it must
# NEVER be compared against a value that lives in a CI secret, because
# that comparison is itself a way to smuggle a catalog-trusted key through
# CI review ("does this repo secret match the catalog key" is a question
# this workflow must never even ask). The release key is a SEPARATE
# keypair, generated via `catalog_console.py keygen --release`, that only
# ever signs release SHA256SUMS manifests -- a CI/secret compromise burns
# this key, not the catalog key.
#
# It proves the two halves of the RELEASE keypair actually match, without
# publishing anything. Run it from the Actions tab after setting or
# rotating the secret.
signing-smoke-test:
name: Signing key smoke test
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install cryptography
- name: Sign a throwaway manifest and verify against the RELEASE pubkey
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
run: |
if [ -z "$RELEASE_SIGNING_KEY" ]; then
echo "FAIL: RELEASE_SIGNING_KEY secret is not set."
echo "Generate the RELEASE key (NOT the catalog key) with:"
echo " python catalog_console.py keygen --release"
echo "then add its seed under Settings -> Actions -> Secrets, via:"
echo " python catalog_console.py show-seed-b64 --release"
exit 1
fi
mkdir -p smoke && echo "smoke test payload" > smoke/hello.txt
python3 scripts/sign_checksums.py generate smoke --out smoke/SHA256SUMS
python3 scripts/sign_checksums.py sign --sums smoke/SHA256SUMS --out smoke/SHA256SUMS.sig
python - <<'PY'
import pathlib, sys
from scripts.sign_checksums import RELEASE_PUBKEYS, verify_checksums_against_any
# Deliberately does NOT import bcc_core / CATALOG_PUBKEYS at all --
# this smoke test must never be able to compare the CI secret
# against the catalog's root of trust (issue #68 finding 5). Only
# RELEASE_PUBKEYS (scripts/sign_checksums.py) is a legitimate
# target for a CI-resident key.
if not RELEASE_PUBKEYS:
sys.exit(
"FAIL: scripts/sign_checksums.RELEASE_PUBKEYS is empty.\n"
"\n"
"Generate the release keypair with:\n"
" python catalog_console.py keygen --release\n"
"then paste the printed public key into RELEASE_PUBKEYS in\n"
"scripts/sign_checksums.py and commit that change."
)
sums = pathlib.Path("smoke/SHA256SUMS").read_text()
sig = pathlib.Path("smoke/SHA256SUMS.sig").read_bytes()
if not verify_checksums_against_any(RELEASE_PUBKEYS, sums, sig):
sys.exit(
"FAIL: the signature produced by RELEASE_SIGNING_KEY does NOT verify\n"
"against any key in scripts/sign_checksums.RELEASE_PUBKEYS.\n"
"\n"
"The secret and the shipped release public key are different keypairs.\n"
"Downloaders would reject every signature this CI produces. Re-copy the\n"
"seed from `catalog_console.py show-seed-b64 --release`, or update\n"
"RELEASE_PUBKEYS with the matching public key."
)
print("OK: RELEASE_SIGNING_KEY matches a key in RELEASE_PUBKEYS.")
PY
# ── Create GitHub Release with all three artifacts ──────────────────────
release:
@@ -107,11 +193,76 @@ jobs:
contents: write
steps:
# Needed for scripts/sign_checksums.py — the release job otherwise
# only downloads build artifacts, it doesn't check out the repo.
- name: Checkout
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: artifacts
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
# download-artifact@v3 nests each artifact under a directory named
# after it (artifacts/<name>/<name>). Flatten into one directory so
# SHA256SUMS lists plain filenames, matching what `sha256sum -c`
# expects when run from inside an extracted release download.
- name: Collect release files
run: |
mkdir -p release-files
find artifacts -type f -exec cp {} release-files/ \;
ls -la release-files
- name: Generate SHA256SUMS
run: python3 scripts/sign_checksums.py generate release-files --out release-files/SHA256SUMS
# ── Sign the checksum manifest (best-effort) ──────────────────────
#
# BCC binaries are not code-signed (no budget for a paid cert). This
# is the free half: a checksum manifest, detached-signed with
# Ed25519, so a tampered download is detectable by anyone who
# checks. It does NOT remove Gatekeeper/SmartScreen warnings.
#
# The private key is a repo secret (RELEASE_SIGNING_KEY, base64 raw
# Ed25519 seed) for the RELEASE key -- a SEPARATE keypair from the
# catalog key, generated via `python catalog_console.py keygen
# --release` (issue #68 finding 5; #62). This key is intentionally
# CI-resident and signs ONLY this checksum manifest; it is never
# trusted to sign data/catalog.json. If it's not set, we still
# publish the release — just without a .sig — rather than fail the
# release outright.
- name: Check for signing key
id: signing
run: |
if [ -n "${{ secrets.RELEASE_SIGNING_KEY }}" ]; then
echo "has_key=true" >> "$GITHUB_OUTPUT"
else
echo "has_key=false" >> "$GITHUB_OUTPUT"
fi
- name: Install signing dependencies
if: steps.signing.outputs.has_key == 'true'
run: pip install cryptography
- name: Sign SHA256SUMS
if: steps.signing.outputs.has_key == 'true'
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
run: |
python3 scripts/sign_checksums.py sign \
--sums release-files/SHA256SUMS \
--out release-files/SHA256SUMS.sig
- name: Warn — release will be unsigned
if: steps.signing.outputs.has_key != 'true'
run: |
echo "::warning::RELEASE_SIGNING_KEY secret is not set — this release is being published WITHOUT a signed SHA256SUMS.sig. Generate the RELEASE key (python catalog_console.py keygen --release) and add its seed (python catalog_console.py show-seed-b64 --release) as this secret before the next tag."
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
@@ -119,7 +270,9 @@ jobs:
draft: false
prerelease: false
generate_release_notes: false
files: artifacts/**/*
files: |
artifacts/**/*
release-files/SHA256SUMS*
body: |
## Better Claude Config ${{ github.ref_name }}
@@ -139,5 +292,8 @@ jobs:
xattr -cr /Applications/BetterClaudeConfig.app
```
### Verifying your download
Every release includes `SHA256SUMS` (and, when the signing key is configured, a detached `SHA256SUMS.sig`). See [Verifying your download](https://git.avezzano.io/the_og/better-claude-config#verifying-your-download) in the README for commands. This proves you got the file we published — it does not remove Gatekeeper/SmartScreen warnings.
### Requirements
No Python installation needed — the app is self-contained.
+6
View File
@@ -5,6 +5,12 @@ __pycache__/
.venv/
venv/
# Test / lint caches
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
# PyInstaller / build
build/
dist/
+15
View File
@@ -0,0 +1,15 @@
# Install once with: pip install pre-commit && pre-commit install
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
+9 -5
View File
@@ -8,10 +8,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
# Install runtime dependency
pip install -r requirements.txt
python bcc.py # run the GUI
python test_core.py # run unit tests (23 tests, no GUI needed)
# Test & lint (no GUI / PySide6 needed — tests only exercise bcc_core)
pip install -r requirements-dev.txt
python -m pytest # unit tests in tests/
ruff check . # lint
ruff format . # format (CI enforces ruff format --check)
# Build a self-contained binary
pip install -r requirements-dev.txt
python scripts/build_icons.py # regenerate icons/app.icns + icons/app.ico if needed
pyinstaller bcc.spec
# macOS → dist/BetterClaudeConfig.app
@@ -19,7 +23,7 @@ pyinstaller bcc.spec
# Linux → dist/BetterClaudeConfig
```
Requires Python 3.10+. Runtime dependency: `PySide6>=6.6`. Build-time: `pyinstaller>=6.0`, `pillow>=10.0`.
Requires Python 3.10+. Runtime dependency: `PySide6>=6.6`. Tooling config (ruff, pytest, project metadata) lives in `pyproject.toml`. CI (`.github/workflows/ci.yml`) runs lint + tests on every push/PR; releases build on tag push (`release.yml`).
## Architecture
@@ -27,9 +31,9 @@ The codebase is split into two layers:
**`bcc_core.py`** — All logic with no GUI imports. Contains:
- `Profile` / `ServerEntry` dataclasses (the data model)
- `discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude/settings.json` (Claude Code, cross-platform)
- `discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude.json` (Claude Code user scope — what `claude mcp add` writes). `~/.claude/settings.json` is NOT a server config (it rejects `mcpServers` with a schema error) and is only surfaced, labelled legacy, if servers are found parked in it. Project-scope `.mcp.json` files can be opened via Add config…
- `load_config` / `extract_servers` / `apply_servers` / `write_config` — the read/write pipeline; writes are atomic with rotating timestamped backups in `.bcc_backups/`
- `parse_pasted_json()` — accepts three JSON shapes (full config, inner map, or bare server object)
- `parse_pasted_json()` / `parse_pasted_json_verbose()` — accepts three JSON shapes (full config, inner map, or bare server object). Input does not have to be valid JSON: `repair_json_text()` auto-fixes markdown fences, surrounding prose, `//` `/* */` `#` comments, trailing/missing commas, smart quotes, single quotes, unquoted keys, Python/JS literals, and unclosed braces. The verbose variant also returns human-readable notes describing every repair applied (shown live in the paste dialog)
- `check_dependency()` / `diagnostics_text()` / `pin_command_path()` — PATH resolution logic; distinguishes "found on normal PATH" (ok) vs "found only on augmented PATH" (warn) vs "not found" (missing). Uses an `lru_cache`-memoized `augmented_path()` that extends the inherited PATH with common runtime locations (nvm, homebrew, cargo, volta, etc.)
- `test_remote()` — synchronous HTTP reachability check, intended to run off the UI thread
+95
View File
@@ -0,0 +1,95 @@
# HANDOFF — BCC feature backlog implementation
For the Claude Code agent working in this repo. Read this whole file, then
CLAUDE.md, before touching code. A supervising Claude session watches this
project through the Gitea MCP (issues, PRs, CI runs on git.avezzano.io), so
keep all state in Gitea where it can be seen: issues for work items, PRs for
changes, comments for decisions.
## Step 0 — sync state
`main` may contain local commits that aren't on origin yet (the previous
session couldn't push). Run `git status` / `git log origin/main..main`; if
there are unpushed commits, **push them first** and confirm CI goes green
before starting anything.
## Step 1 — file the backlog as issues
Create one Gitea issue per item in the backlog table below (`tea` CLI or the
API — you have full credentials). Prefix titles with the ID (e.g.
"[#2] Stdio server spawn-test"). Create labels P0/P1/P2 first if they don't
exist. Copy each item's full section from `../bcc-feature-requests.md`
(sibling of this repo folder) into the issue body — it has problem statements,
evidence, proposed fixes, and acceptance criteria.
**Corrections to that document — do not re-implement:**
- "Doc correction: README Claude Code path" — DONE (commit `b16c0fd`).
- Item 1 (secret masking) — MOSTLY DONE (commit `6dee318`): env/header values
masked via delegate + Show-secrets toggle, Add-dialog password echo,
diagnostics args redaction (`redact_args`). **Only remainder:** the warning
badge when a secret-shaped value sits in `args` instead of `env`. File the
issue scoped to just that.
## Step 2 — work the backlog
| Order | ID | Item | Priority | Notes |
|---|---|---|---|---|
| 1 | 2 | Stdio server spawn-test | P0 | Spawn with timeout, capture stderr; core logic in bcc_core (GUI-free, testable) |
| 2 | 3 | Backup restore UI | P0 | List `.bcc_backups/`, preview/diff, restore through normal atomic-write path |
| 3 | 4 | Stale-file protection | P0 | mtime/hash at load, re-check before write, prompt on conflict |
| 4 | 1r | Secret-in-args warning badge | P0 | Remainder of item 1; `is_secret_key`/`_is_secret_value` already exist in bcc_core |
| 5 | 8 | Duplicate-name conflict on paste/import | P1 | VERIFY FIRST: check `paste_json`/`dropEvent` in bcc.py for silent overwrite; file findings on the issue before coding |
| 6 | 5 | Cross-client support (Cursor/Windsurf/VS Code) | P1 | VS Code uses `servers` not `mcpServers` — needs a read/write adapter, not just paths |
| 7 | 6 | In-app MCP log viewer | P1 | Platform log paths in the doc |
| 8 | 7 | Windows MSIX path detection | P1 | Can't test locally on macOS — unit-test the detection logic, note that in the PR |
| 9 | 9 | Restart Claude Desktop button | P1 | Platform-specific process handling; scope to Desktop only |
| 10 | 10 | Server catalog / one-click add | P2 | Design pass first — post a proposal as an issue comment before building |
## Workflow rules
- **Branch per issue** off `main`: `feat/<issue-number>-short-slug` (or
`fix/`). One issue per PR. Reference the issue in the PR description
(`Closes #N`).
- **Before every commit:** `python -m pytest` (all green), `ruff check .`,
`ruff format .`. CI enforces all three.
- **Conventional commits** (`feat:`, `fix:`, `test:`, `docs:`, `chore:`) —
match the existing history's style: body explains the why.
- **Merge only with green CI.** If the supervising session has flagged
something on the PR, resolve it before merging.
## Architecture guardrails (violating these fails review)
1. **The cardinal rule:** config writes only ever touch `mcpServers` and
`_disabledMcpServers`. Every other key is preserved verbatim, in order.
This matters extra for `~/.claude.json`, which holds conversation history
and project state.
2. **Core/GUI split:** all logic in `bcc_core.py` (no GUI imports, unit-
testable); `bcc.py` stays a thin PySide6 shell. New logic gets tests in
`tests/` — they run without PySide6, keep it that way.
3. **No new runtime dependencies** without posting the justification on the
issue first. PySide6 is currently the only one.
4. **Writes are atomic + backed up** — route any new disk writes through
`write_config()`.
5. Secrets never appear in diagnostics output or logs — use `redact_args`/
`is_secret_key` from bcc_core.
## Current state (as of this handoff)
- **92 tests** in `tests/test_core.py`, ruff clean, CI = lint + pytest (py3.10/3.12).
- `main` = `ece049c` (PR #16 merged). No unpushed commits.
- **Completed items:** #2 (spawn-test, PR #11), #3 (backup restore UI, PR #14),
#4 (stale-file protection, PR #15), #1r (secret-in-args warning, PR #16).
- **Next item:** #8 — duplicate-name conflict handling. **VERIFY FIRST:** read
`paste_json` and `dropEvent` in `bcc.py` to check whether pasting/dropping a
server with a name that already exists silently overwrites it; file your
findings as a comment on issue #8 before writing any code.
- Recent features you should know exist: lenient JSON repair
(`repair_json_text`, `parse_pasted_json_verbose`, `repair_config_file` +
RepairDialog), ArgsEdit numbered gutter + `split_suspicious_args`,
resizable splitters with QSettings persistence, secret masking, Claude Code
profile at `~/.claude.json` (settings.json only as "legacy" when it holds
parked servers), backup restore UI (RestoreDialog, `list_backups`,
`backup_diff`, `restore_backup`), stale-file protection (StaleDialog,
`config_mtime`, `external_change_summary`).
- GUI can't be smoke-tested in CI; note in each PR whether you ran
`python bcc.py` locally and what you checked.
+111 -5
View File
@@ -19,6 +19,103 @@ Pre-built self-contained binaries are attached to every [GitHub Release](../../r
> **macOS Gatekeeper note:** the app is not notarized. On first launch, right-click → **Open**, or run `xattr -cr /Applications/BetterClaudeConfig.app` in a terminal.
## Verifying your download
BCC isn't code-signed — there's no budget for a paid certificate (macOS
Developer ID, Windows Authenticode). Instead, every release publishes a
`SHA256SUMS` file listing the checksum of each archive, detached-signed with
Ed25519 as `SHA256SUMS.sig`. Both are attached to the release alongside the
binaries.
**What this proves:** the file you downloaded is byte-for-byte what we
published, and the manifest itself was signed by our release key.
**What this does NOT do:** it does not make the binary "safe," and it does
**not** remove the macOS Gatekeeper or Windows SmartScreen warning — those
are only suppressed by a paid OS-vendor certificate, which this project
doesn't have. Verifying checksums is about detecting tampering in transit or
on a mirror, not about vouching for the software.
This manifest is signed with BCC's **release key**, which is a different
key from the one that signs the MCP server catalog — see
[Signing keys](#signing-keys) below for why, and for the public key value
to use with `--pubkey-b64` below.
### macOS / Linux
```bash
# From inside the folder you downloaded the release files into:
sha256sum -c SHA256SUMS
```
If your `sha256sum` complains about missing files, download `SHA256SUMS`
into the same directory as the archive you downloaded — it lists every
platform's archive, and only the one(s) present will be checked.
To also verify the manifest's signature (optional, requires Python +
`pip install cryptography` and a checkout of this repo):
```bash
python3 scripts/sign_checksums.py verify \
--sums SHA256SUMS --sig SHA256SUMS.sig \
--pubkey-b64 "<the release public key from Signing keys, below>"
```
### Windows (PowerShell)
```powershell
Get-FileHash .\BetterClaudeConfig-Windows.zip -Algorithm SHA256
```
Compare the printed hash (case-insensitively) against the matching line in
`SHA256SUMS`.
### If a release has no `SHA256SUMS.sig`
The signing key is a repo secret that has to be configured manually; if a
release is missing the `.sig` file, the checksums themselves are still
valid and safe to check against — the release workflow only skips signing,
never checksum generation.
## Signing keys
BCC uses **two separate Ed25519 keypairs**, deliberately never the same
key, because they protect different things and live in different places:
| | Catalog key | Release key |
|---|---|---|
| Signs | `data/catalog.json` (the MCP server catalog every user's app trusts) | `SHA256SUMS` (the checksum manifest for release binaries) |
| Verified by | `bcc_core.CATALOG_PUBKEYS` | `scripts/sign_checksums.RELEASE_PUBKEYS` |
| Lives | Offline, passphrase-encrypted, maintainer's machine only (OS keychain or an encrypted file outside the repo — see the [Catalog Console](#files), issue #62) | A Gitea Actions repo secret, `RELEASE_SIGNING_KEY`**intentionally CI-resident** |
| 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` |
**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
the shipped catalog is only there because this key signed it. If that key
and the release-checksum key were the same (as they briefly were — see
[issue #68](../../issues/68)), then anything that can exfiltrate a Gitea
Actions secret (a malicious workflow-file PR, a compromised runner, a leaky
log) could sign a catalog every user's copy of BCC would trust, not just a
checksum manifest. Splitting them means **a CI/secret compromise burns the
release key, never the catalog key** — checksums for a future release could
be forged, which is bad, but no attacker gains the ability to make BCC run
arbitrary commands on installs that trust the catalog. That asymmetry is
the entire point of having two keys instead of one.
The catalog key is **never** meant to leave the maintainer's machine: it's
generated, stored, unlocked, and used to sign entirely inside the Catalog
Console (`catalog_console.py`), and `catalog_console.py show-seed-b64`
refuses to run without `--release` specifically so the catalog seed can't
be exported by habit or muscle memory.
**Release signing public key** (Ed25519, base64, raw 32 bytes) — this is
the RELEASE key, not the catalog key:
```
<PLACEHOLDER — AJ: paste the release public key from `catalog_console.py keygen --release` here>
```
## Run from source
```bash
@@ -32,12 +129,17 @@ python bcc.py
- **Auto-discovers installs** — scans the platform's app-support folder for any
`Claude*` directory (so `Claude` and `Claude-Work` both show up) **and** finds
Claude Code at `~/.claude/settings.json`. Use **Add config…** to point at any
other file manually.
Claude Code's user-scope config at `~/.claude.json` (the file `claude mcp add`
writes). Use **Add config…** to point at any other file manually — e.g. a
project's `.mcp.json`.
- **Form-based editing** — name, command, args (one per line), env vars, or for
remote servers: URL, transport, and headers. No raw JSON.
- **Paste JSON** — drop in any snippet from an MCP doc (full `mcpServers` block,
inner map, or a single bare server object); it's parsed and merged.
- **Paste JSON — even broken JSON** — drop in any snippet from an MCP doc (full
`mcpServers` block, inner map, or a single bare server object); it's parsed
and merged. The paste box parses as you type and auto-repairs the stuff docs
and chat windows love to break: markdown fences, surrounding prose, comments,
trailing or missing commas, smart quotes, single quotes, unquoted keys, and
unclosed braces — and tells you exactly what it fixed before you commit.
- **Drag & drop** a `.json` file onto the window to import servers from it.
- **Copy to ▸** — copy the selected server straight into your *other* install.
- **Active / Disabled sections** — servers are shown in two labelled lists with
@@ -76,7 +178,9 @@ After saving, **restart that Claude install** for changes to take effect.
| Windows | `%APPDATA%` |
| Linux | `~/.config` |
**Claude Code** (all platforms): `~/.claude/settings.json`
**Claude Code** (all platforms): `~/.claude.json` (user scope). If servers are
found parked in `~/.claude/settings.json` — where Claude Code ignores them — that
file is also listed, marked *legacy*, so you can copy them over.
## Files
@@ -85,6 +189,8 @@ After saving, **restart that Claude install** for changes to take effect.
- `test_core.py` — unit suite for the core (`python test_core.py`).
- `bcc.spec` — PyInstaller build spec (cross-platform).
- `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)).
- `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
+1891 -164
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -31,7 +31,7 @@ a = Analysis(
["bcc.py"],
pathex=[],
binaries=[],
datas=[],
datas=[("icons", "icons"), ("data/catalog.json", "data")],
hiddenimports=[],
hookspath=[],
hooksconfig={},
@@ -78,8 +78,8 @@ if sys.platform == "darwin":
info_plist={
"CFBundleName": "Better Claude Config",
"CFBundleDisplayName": "Better Claude Config",
"CFBundleShortVersionString": "1.0.0",
"CFBundleVersion": "1.0.0",
"CFBundleShortVersionString": "1.3.0",
"CFBundleVersion": "1.3.0",
"NSHighResolutionCapable": True,
"NSRequiresAquaSystemAppearance": False, # supports dark mode
"LSMinimumSystemVersion": "11.0",
@@ -98,7 +98,7 @@ else:
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx=False, # UPX can strip icon resources from the PE on Windows
upx_exclude=[],
runtime_tmpdir=None,
console=False,
+2688 -63
View File
File diff suppressed because it is too large Load Diff
+1040
View File
File diff suppressed because it is too large Load Diff
+818
View File
@@ -0,0 +1,818 @@
"""
catalog_review.py -- pure, GUI-free review/diff/risk/crypto logic for the
Catalog Console (issue #62).
This module is deliberately Qt-free and network-free so every function in it
is unit-testable offline, exactly like bcc_core.py. catalog_console.py (the
PySide6 GUI) is a thin shell over these functions -- it owns Qt widgets,
subprocess/git calls, and HTTP registry lookups; this module owns judgment.
Nothing here is reimplemented from bcc_core: the command allowlist and the
signature domain-separation prefix are imported, not retyped, so the two
modules cannot silently drift apart (see bcc_core.validate_catalog /
bcc_core.verify_catalog_signature and the project's "the check drifted on a
new surface" recurring-bug lesson).
"""
from __future__ import annotations
import os
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from urllib.parse import urlsplit
from bcc_core import _CATALOG_SIG_DOMAIN as CATALOG_SIG_DOMAIN
from bcc_core import CATALOG_ALLOWED_COMMANDS
from bcc_core import verify_catalog_signature as _verify_catalog_signature
# --------------------------------------------------------------------------- #
# Semantic diff
# --------------------------------------------------------------------------- #
# Top-level scalar/simple fields compared directly (not drilled into).
_DIFF_FIELDS = (
"display",
"description",
"category",
"official",
"setup",
"homepage",
"docs_url",
"source",
"notes",
"stars",
"last_release",
"env_required",
)
@dataclass(frozen=True)
class FieldChange:
"""One field that differs between the old and new version of an entry."""
field: str
old: object
new: object
@dataclass(frozen=True)
class EntryChange:
"""One catalog entry's change: added, removed, or changed.
`old`/`new` are the raw entry dicts (or None for added/removed) so risk
predicates and the GUI can inspect anything not captured by
`field_changes` (which only lists fields that actually differ).
"""
entry_id: str
status: str # "added" | "removed" | "changed"
old: dict | None
new: dict | None
field_changes: tuple[FieldChange, ...] = ()
def _config_field_changes(old_cfg: dict | None, new_cfg: dict | None) -> list[FieldChange]:
old_cfg = old_cfg or {}
new_cfg = new_cfg or {}
changes: list[FieldChange] = []
for f in ("command", "args", "env"):
ov, nv = old_cfg.get(f), new_cfg.get(f)
if ov != nv:
changes.append(FieldChange(f"config.{f}", ov, nv))
return changes
def _entry_field_changes(old_entry: dict, new_entry: dict) -> tuple[FieldChange, ...]:
changes: list[FieldChange] = []
for f in _DIFF_FIELDS:
ov, nv = old_entry.get(f), new_entry.get(f)
if ov != nv:
changes.append(FieldChange(f, ov, nv))
changes.extend(_config_field_changes(old_entry.get("config"), new_entry.get("config")))
return tuple(changes)
def diff_catalogs(old: dict | None, new: dict | None) -> list[EntryChange]:
"""Semantic (per-entry) diff between two parsed catalog dicts.
NOT a text diff: entries are matched by `id`, and each changed entry
reports exactly which fields differ (with before/after values), which is
what lets the Console render "command changed from X to Y" instead of a
JSON line diff a reviewer has to mentally reconstruct.
Entries missing/malformed `id` are ignored here -- that is a
validate_catalog() rejection, not a diffing concern, and diffing must not
silently invent a match for two differently-broken entries.
"""
old_servers = {
e["id"]: e
for e in (old or {}).get("servers", []) or []
if isinstance(e, dict) and isinstance(e.get("id"), str) and e.get("id")
}
new_servers = {
e["id"]: e
for e in (new or {}).get("servers", []) or []
if isinstance(e, dict) and isinstance(e.get("id"), str) and e.get("id")
}
changes: list[EntryChange] = []
for entry_id in sorted(set(old_servers) | set(new_servers)):
old_e = old_servers.get(entry_id)
new_e = new_servers.get(entry_id)
if old_e is None:
changes.append(EntryChange(entry_id, "added", None, new_e, ()))
elif new_e is None:
changes.append(EntryChange(entry_id, "removed", old_e, None, ()))
elif old_e != new_e:
fc = _entry_field_changes(old_e, new_e)
if fc:
changes.append(EntryChange(entry_id, "changed", old_e, new_e, fc))
return changes
# --------------------------------------------------------------------------- #
# Risk annotations -- each predicate is pure and independently unit-tested.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class RiskFinding:
severity: str # "blocking" | "warning" | "info"
code: str
message: str
def _escape_non_ascii(s: str) -> str:
"""Render a string with any non-ASCII code point shown as an escape
sequence, so a homoglyph/RTL-override character can't visually pass as
the real thing in the review UI."""
return s.encode("unicode_escape").decode("ascii")
def risk_env_required(change: EntryChange) -> list[RiskFinding]:
"""A non-empty env_required value is blocking: catalog entries must ship
only the *names* of env vars the user fills in, never values."""
entry = change.new or {}
env_required = entry.get("env_required")
findings: list[RiskFinding] = []
if isinstance(env_required, dict):
for k, v in env_required.items():
if v not in (None, ""):
findings.append(
RiskFinding(
"blocking",
"env_required_value",
f"env_required[{k!r}] carries a non-empty value -- catalog "
"entries must never ship secret values, only placeholder names.",
)
)
return findings
def risk_command_allowlist(change: EntryChange) -> list[RiskFinding]:
"""A command outside bcc_core.CATALOG_ALLOWED_COMMANDS is blocking.
Imports the allowlist rather than redefining it."""
entry = change.new or {}
config = entry.get("config") or {}
command = config.get("command")
if isinstance(command, str) and command and command not in CATALOG_ALLOWED_COMMANDS:
return [
RiskFinding(
"blocking",
"command_not_allowed",
f"command {command!r} is not on the catalog allowlist "
f"({', '.join(sorted(CATALOG_ALLOWED_COMMANDS))}).",
)
]
return []
def risk_non_ascii(change: EntryChange) -> list[RiskFinding]:
"""Non-ASCII code points in id/command/args are blocking -- homoglyph /
RTL-override typosquatting can make a malicious package name visually
identical to a legitimate one in a naive diff view."""
entry = change.new or {}
findings: list[RiskFinding] = []
entry_id = entry.get("id")
if isinstance(entry_id, str) and not entry_id.isascii():
findings.append(
RiskFinding(
"blocking",
"non_ascii_id",
f"id contains non-ASCII code points: {_escape_non_ascii(entry_id)!r}",
)
)
config = entry.get("config") or {}
command = config.get("command")
if isinstance(command, str) and not command.isascii():
findings.append(
RiskFinding(
"blocking",
"non_ascii_command",
f"command contains non-ASCII code points: {_escape_non_ascii(command)!r}",
)
)
for a in config.get("args") or []:
if isinstance(a, str) and not a.isascii():
findings.append(
RiskFinding(
"blocking",
"non_ascii_arg",
f"arg contains non-ASCII code points: {_escape_non_ascii(a)!r}",
)
)
return findings
def _npm_candidate_args(command: str | None, args: list[str]) -> list[str]:
if command != "npx":
return []
return [a for a in args if isinstance(a, str) and a and not a.startswith("-")]
def split_npm_spec(spec: str) -> tuple[str, str | None]:
"""Split an npm package spec into (name, version). version is None if
unpinned. Handles scoped (@scope/name@version) and unscoped
(name@version) specs."""
if spec.startswith("@"):
rest = spec[1:]
if "/" not in rest:
return spec, None # malformed scope, can't tell -- treat unpinned
scope, _, remainder = rest.partition("/")
if "@" in remainder:
pkg_name, _, version = remainder.partition("@")
return f"@{scope}/{pkg_name}", (version or None)
return f"@{scope}/{remainder}", None
if "@" in spec:
name, _, version = spec.partition("@")
return name, (version or None)
return spec, None
def is_pinned_npm_spec(spec: str) -> bool:
_name, version = split_npm_spec(spec)
return bool(version)
_DOCKER_VALUE_FLAGS = {
"-e",
"--env",
"-v",
"--volume",
"-p",
"--publish",
"-w",
"--workdir",
"-u",
"--user",
"--name",
"--network",
"--entrypoint",
}
def docker_image_candidates(args: list[str]) -> list[str]:
"""Best-effort extraction of the image reference from a `docker run
[OPTIONS] IMAGE [CMD...]` args list: the first positional token after
any leading `run` and flag(+value) pairs."""
candidates: list[str] = []
i = 0
while i < len(args):
a = args[i]
if a == "run":
i += 1
continue
if isinstance(a, str) and a.startswith("-"):
if "=" not in a and a in _DOCKER_VALUE_FLAGS:
i += 2
continue
i += 1
continue
if isinstance(a, str):
candidates.append(a)
break # first positional token after `run` is the image ref
return candidates
def is_pinned_docker_image(image: str) -> bool:
if "@sha256:" in image:
return True
tag_part = image.rsplit("/", 1)[-1]
if ":" not in tag_part:
return False # no tag => implicit :latest
tag = tag_part.rsplit(":", 1)[-1]
return bool(tag) and tag != "latest"
def risk_unpinned_package(change: EntryChange) -> list[RiskFinding]:
"""Every entry must pin an exact version: an `@scope/pkg` npm arg with
no `@version`, or a docker image with no tag / `:latest`, is blocking.
A later-compromised package must not be able to auto-upgrade into every
user just because the catalog entry never pinned a version."""
entry = change.new or {}
config = entry.get("config") or {}
command = config.get("command")
args = config.get("args") or []
findings: list[RiskFinding] = []
if command == "npx":
for a in _npm_candidate_args(command, args):
if not is_pinned_npm_spec(a):
findings.append(
RiskFinding(
"blocking",
"unpinned_npm_package",
f"npm package arg {a!r} has no pinned @version.",
)
)
elif command == "docker":
for img in docker_image_candidates(args):
if not is_pinned_docker_image(img):
findings.append(
RiskFinding(
"blocking",
"unpinned_docker_image",
f"docker image {img!r} is not pinned to an exact tag "
"(uses :latest or no tag).",
)
)
return findings
_URL_FIELDS = ("homepage", "docs_url", "source")
def _domain(url: str) -> str:
try:
return urlsplit(url).netloc.lower()
except ValueError:
return ""
def risk_url_domain_change(change: EntryChange) -> list[RiskFinding]:
"""Non-https URLs and, more importantly, a *domain change* on any URL
field are surfaced loudly with old-vs-new domains broken out -- the
lookalike-domain-swap defence."""
findings: list[RiskFinding] = []
old_entry = change.old or {}
new_entry = change.new or {}
for f in _URL_FIELDS:
new_url = new_entry.get(f)
if not isinstance(new_url, str) or not new_url:
continue
if not new_url.startswith("https://"):
findings.append(
RiskFinding("warning", "non_https_url", f"{f} is not https://: {new_url!r}")
)
old_url = old_entry.get(f)
if isinstance(old_url, str) and old_url:
old_domain, new_domain = _domain(old_url), _domain(new_url)
if old_domain and new_domain and old_domain != new_domain:
findings.append(
RiskFinding(
"warning",
"domain_changed",
f"{f} domain changed from {old_domain!r} to {new_domain!r} -- "
"verify this isn't a lookalike-domain swap.",
)
)
return findings
def risk_new_entry(change: EntryChange) -> list[RiskFinding]:
"""A brand-new entry is flagged for extra scrutiny -- not blocking on its
own, but it's the category of change the registry lookup exists for."""
if change.status == "added":
return [
RiskFinding(
"info",
"new_entry",
"Brand-new catalog entry -- extra scrutiny: check publisher identity "
"via the registry lookup before signing.",
)
]
return []
_RISK_PREDICATES: tuple[Callable[[EntryChange], list[RiskFinding]], ...] = (
risk_env_required,
risk_command_allowlist,
risk_non_ascii,
risk_unpinned_package,
risk_url_domain_change,
risk_new_entry,
)
def entry_risk_findings(change: EntryChange) -> list[RiskFinding]:
"""Run every risk predicate against one entry change and return the
combined findings (order matches _RISK_PREDICATES)."""
findings: list[RiskFinding] = []
for predicate in _RISK_PREDICATES:
findings.extend(predicate(change))
return findings
def has_blocking_risk(change: EntryChange) -> bool:
return any(f.severity == "blocking" for f in entry_risk_findings(change))
# --------------------------------------------------------------------------- #
# Review session: acknowledge-gating + TOCTOU blob-SHA pinning
# --------------------------------------------------------------------------- #
@dataclass
class ReviewSession:
"""State for one review pass. `pinned_blob_sha` is the git blob SHA of
data/catalog.json as it existed the moment review began -- see
can_sign()/sign_precondition().
`loaded_ref` is the exact ref this review was loaded from ("main", or a
PR's `refs/pull/<n>/head`) -- see issue #68 finding 1. It exists so the
Sign path can re-resolve the TOCTOU blob SHA from *the ref that was
actually reviewed*, instead of a hardcoded "main" that silently diverges
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
main-vs-itself path instead).
"""
pinned_blob_sha: str
old_catalog: dict
new_catalog: dict
loaded_ref: str = "main"
changes: list[EntryChange] = field(default_factory=list)
acknowledged: set[str] = field(default_factory=set)
def __post_init__(self) -> None:
if not self.changes:
self.changes = diff_catalogs(self.old_catalog, self.new_catalog)
def start_review(
pinned_blob_sha: str,
old_catalog: dict,
new_catalog: dict,
loaded_ref: str = "main",
) -> ReviewSession:
return ReviewSession(
pinned_blob_sha=pinned_blob_sha,
old_catalog=old_catalog,
new_catalog=new_catalog,
loaded_ref=loaded_ref,
)
def find_last_signed_catalog_raw(
candidates: list[bytes], sig: bytes, pubkeys: list[bytes]
) -> bytes | None:
"""Given `candidates` (candidate raw catalog.json byte-strings -- e.g.
successive historical versions from git log, most-recent-first),
return the first one whose signature verifies against `sig`/`pubkeys`,
or None if none do.
This is how source="main" review diffs against "the last catalog a
maintainer actually signed" instead of against itself (issue #68
finding 1): `catalog_console.last_signed_catalog_raw` walks
data/catalog.json's git history on main and hands the candidates here.
"""
for raw in candidates:
if _verify_catalog_signature(raw, sig, pubkeys):
return raw
return None
def acknowledge_entry(session: ReviewSession, entry_id: str) -> None:
ids = {c.entry_id for c in session.changes}
if entry_id not in ids:
raise ValueError(f"{entry_id!r} is not part of this review session's diff.")
session.acknowledged.add(entry_id)
def unacknowledge_entry(session: ReviewSession, entry_id: str) -> None:
session.acknowledged.discard(entry_id)
def all_entries_acknowledged(session: ReviewSession) -> bool:
return {c.entry_id for c in session.changes} <= session.acknowledged
# NOTE for future editors: do NOT add an "acknowledge all" shortcut here, now
# or ever. The friction of individually acknowledging every changed entry is
# the entire point of this tool (issue #62) -- a shortcut would let a tired
# reviewer rubber-stamp a diff exactly like the "merge PR, run script, push"
# reflex this Console exists to replace. If this comment is the only thing
# stopping you, that is the point: it is stopping you on purpose.
@dataclass(frozen=True)
class SignDecision:
ok: bool
reason: str | None = None
def can_sign(session: ReviewSession, current_blob_sha: str) -> SignDecision:
"""Whether the Sign button may fire right now.
Four independent gates, all required, checked in this order:
0. The diff must be non-empty. An empty diff historically meant "Sign
unlocks instantly" (`set() <= set()` is vacuously True), which is
exactly backwards: a vacuously-satisfied gate is worse than no gate
at all, because it *manufactures confidence* -- the signature looks
identical to one produced by a real review. "Nothing changed" must
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
ever reviewed.)
1. TOCTOU: `current_blob_sha` (fetched fresh, immediately before signing,
from the ref that was actually reviewed -- see sign_precondition())
must match the blob SHA pinned when review began. If the bytes on the
remote changed since -- a new commit pushed to the same PR, a
force-push, another PR merged in between -- signing is refused and a
re-review is forced. This is what makes "signing is the approval act"
true rather than aspirational: the signature is bound to the exact
reviewed bytes, not to "whatever the file happens to be now".
2. No blocking risk finding may be outstanding on ANY changed entry, full
stop -- checked here, not just in the GUI. The GUI additionally
disables the acknowledge checkbox for a blocking entry, but that is a
UI nicety, not the enforcement point: if this pure gate didn't also
check it, a blocking risk would only be stopped by the GUI happening
to have wired the checkbox correctly, and nothing would catch a
regression in that wiring. The GUI must not be the only thing
standing between a blocking risk and a signature.
3. Every changed entry in the diff must be individually acknowledged.
"""
if not session.changes:
return SignDecision(
False,
"Nothing to sign: this review's diff is empty. If you expected "
"changes here, you may be diffing the wrong source/ref.",
)
if current_blob_sha != session.pinned_blob_sha:
return SignDecision(
False,
"The reviewed bytes changed since this review began (blob SHA "
"mismatch) -- re-review required before signing.",
)
blocking_ids = sorted({c.entry_id for c in session.changes if has_blocking_risk(c)})
if blocking_ids:
return SignDecision(
False,
"Blocking risk finding(s) outstanding on: "
f"{', '.join(blocking_ids)} -- fix the underlying change, do not sign around it.",
)
if not all_entries_acknowledged(session):
pending = sorted({c.entry_id for c in session.changes} - session.acknowledged)
return SignDecision(
False, f"Not every changed entry has been acknowledged yet: {', '.join(pending)}"
)
return SignDecision(True, None)
def sign_precondition(
session: ReviewSession, resolve_blob_sha: Callable[[str], str]
) -> SignDecision:
"""The real Sign-button gate: resolves the current TOCTOU blob SHA from
*the ref this session was actually loaded from* (`session.loaded_ref`),
never a hardcoded "main", then delegates to can_sign().
`resolve_blob_sha` is injected so this stays testable without git/Qt --
catalog_console.ReviewWindow._on_sign passes a real resolver
(fetch_ref + blob_sha_at against self.repo_dir); tests pass a fake
dict-backed lookup. This is the fix for issue #68 finding 1's first bug:
`_on_sign` used to hardcode `fetch_ref(self.repo_dir, "main")` as the
comparison ref, so for any PR review (where `loaded_ref` is the PR's
head, not main) the SHAs differed by definition and Sign could never
fire -- and the retry path re-called the same hardcoded resolver, so it
re-pinned the same wrong value and looped forever instead of forcing a
genuine re-review.
"""
current_blob_sha = resolve_blob_sha(session.loaded_ref)
return can_sign(session, current_blob_sha)
def catalog_signing_message(raw_bytes: bytes) -> bytes:
"""The exact bytes that get signed: bcc_core's domain-separation prefix
(imported, never retyped) + the raw catalog bytes. Using this function
guarantees the Console's signature and bcc_core.verify_catalog_signature
can never drift apart on the prefix."""
return CATALOG_SIG_DOMAIN + raw_bytes
# --------------------------------------------------------------------------- #
# Key management: passphrase-encrypted-at-rest Ed25519 seed
#
# The private key is NEVER stored plaintext, never an env var, never
# committed. encrypt_private_key/decrypt_private_key are pure and offline
# (scrypt KDF + AES-256-GCM via `cryptography`, already a project
# dependency); catalog_console.py decides WHERE the resulting blob lives
# (OS keychain if available, else a file outside the repo).
# --------------------------------------------------------------------------- #
_KDF_SALT_LEN = 16
_KDF_N = 2**15 # scrypt cost parameter, tuned for a one-off interactive unlock
_KDF_R = 8
_KDF_P = 1
_NONCE_LEN = 12
_AAD = b"bcc-catalog-console-key-v1"
def _derive_key(passphrase: str, salt: bytes) -> bytes:
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
kdf = Scrypt(salt=salt, length=32, n=_KDF_N, r=_KDF_R, p=_KDF_P)
return kdf.derive(passphrase.encode("utf-8"))
def generate_keypair() -> tuple[bytes, bytes]:
"""Generate a new Ed25519 keypair. Returns (seed_32_bytes, pubkey_32_bytes)."""
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
private_key = Ed25519PrivateKey.generate()
seed = private_key.private_bytes_raw()
pubkey = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
return seed, pubkey
def encrypt_private_key(seed: bytes, passphrase: str) -> bytes:
"""Encrypt a 32-byte Ed25519 seed at rest with a passphrase. Returns a
self-contained blob: salt || nonce || ciphertext+tag."""
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
if len(seed) != 32:
raise ValueError(f"expected a 32-byte raw Ed25519 seed, got {len(seed)} bytes")
if not passphrase:
raise ValueError("a non-empty passphrase is required")
salt = os.urandom(_KDF_SALT_LEN)
key = _derive_key(passphrase, salt)
nonce = os.urandom(_NONCE_LEN)
ciphertext = AESGCM(key).encrypt(nonce, seed, _AAD)
return salt + nonce + ciphertext
def decrypt_private_key(blob: bytes, passphrase: str) -> bytes:
"""Decrypt a blob produced by encrypt_private_key. Raises ValueError on a
wrong passphrase or corrupt blob -- never silently returns garbage."""
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
if len(blob) < _KDF_SALT_LEN + _NONCE_LEN:
raise ValueError("key blob is too short to be valid")
salt = blob[:_KDF_SALT_LEN]
nonce = blob[_KDF_SALT_LEN : _KDF_SALT_LEN + _NONCE_LEN]
ciphertext = blob[_KDF_SALT_LEN + _NONCE_LEN :]
key = _derive_key(passphrase, salt)
try:
return AESGCM(key).decrypt(nonce, ciphertext, _AAD)
except InvalidTag as e:
raise ValueError("wrong passphrase or corrupted key file") from e
def sign_catalog_bytes(raw: bytes, seed: bytes) -> bytes:
"""Sign `raw` catalog bytes with a 32-byte Ed25519 seed, using the exact
domain-separated message bcc_core.verify_catalog_signature expects."""
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
if len(seed) != 32:
raise ValueError(f"expected a 32-byte raw Ed25519 seed, got {len(seed)} bytes")
private_key = Ed25519PrivateKey.from_private_bytes(seed)
return private_key.sign(catalog_signing_message(raw))
# --------------------------------------------------------------------------- #
# Registry lookup -- the check a human genuinely can't do.
#
# The network call itself is injected (a `Fetcher` callable) so this stays
# testable offline; catalog_console.py supplies the real npm/PyPI HTTP
# fetcher. Fails soft everywhere: a fetcher returning None/raising just
# yields RegistryInfo(available=False), never an exception into the caller
# and never a block on review.
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class PackageRef:
entry_id: str
ecosystem: str # "npm" | "pypi"
name: str
version: str | None
def split_pypi_spec(spec: str) -> tuple[str, str | None]:
for sep in ("==", "@"):
if sep in spec:
name, _, version = spec.partition(sep)
return name, (version or None)
return spec, None
def extract_package_refs(entry: dict) -> list[PackageRef]:
"""Pull out the package(s) a basic-tier entry's args reference, for the
registry lookup. Returns [] for link-only entries or entries whose
command isn't npx/uvx (docker images aren't registry-lookup candidates
in the npm/PyPI sense used here)."""
config = entry.get("config") or {}
command = config.get("command")
args = config.get("args") or []
entry_id = entry.get("id", "") if isinstance(entry.get("id"), str) else ""
refs: list[PackageRef] = []
if command == "npx":
for a in _npm_candidate_args(command, args):
name, version = split_npm_spec(a)
if name:
refs.append(PackageRef(entry_id, "npm", name, version))
elif command == "uvx":
for a in args:
if isinstance(a, str) and a and not a.startswith("-"):
name, version = split_pypi_spec(a)
if name:
refs.append(PackageRef(entry_id, "pypi", name, version))
break # `uvx <pkg>` -- first positional token is the package
return refs
@dataclass(frozen=True)
class RegistryInfo:
ref: PackageRef
available: bool
publisher: str | None = None
age_days: int | None = None
last_release: str | None = None
downloads: int | None = None
near_neighbor_ids: tuple[str, ...] = ()
Fetcher = Callable[[PackageRef], dict | None]
def edit_distance(a: str, b: str) -> int:
"""Levenshtein distance, iterative DP (no recursion depth concerns)."""
if a == b:
return 0
la, lb = len(a), len(b)
if la == 0:
return lb
if lb == 0:
return la
prev = list(range(lb + 1))
for i, ca in enumerate(a, 1):
cur = [i] + [0] * lb
for j, cb in enumerate(b, 1):
cost = 0 if ca == cb else 1
cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
prev = cur
return prev[lb]
def near_neighbor_ids(name: str, other_ids: list[str], max_distance: int = 2) -> list[str]:
"""Catalog ids within `max_distance` edits of `name` (case-insensitive),
excluding an exact match -- the dependency-confusion / typosquat
near-neighbour warning."""
lname = name.lower()
return [
oid
for oid in other_ids
if oid != name and edit_distance(lname, oid.lower()) <= max_distance
]
def lookup_registry_info(
ref: PackageRef, fetcher: Fetcher, all_entry_ids: list[str]
) -> RegistryInfo:
"""Resolve one package against the live registry via the injected
fetcher. Never raises: any fetcher exception or falsy return means
`available=False` ("unavailable"), which the GUI renders plainly rather
than blocking or erroring the review."""
neighbors = tuple(near_neighbor_ids(ref.name, all_entry_ids))
try:
raw = fetcher(ref)
except Exception:
raw = None
if not raw:
return RegistryInfo(ref=ref, available=False, near_neighbor_ids=neighbors)
return RegistryInfo(
ref=ref,
available=True,
publisher=raw.get("publisher"),
age_days=raw.get("age_days"),
last_release=raw.get("last_release"),
downloads=raw.get("downloads"),
near_neighbor_ids=neighbors,
)
_NON_ASCII_RE = re.compile(r"[^\x00-\x7f]")
def contains_non_ascii(s: str) -> bool:
return bool(_NON_ASCII_RE.search(s))
+454
View File
@@ -0,0 +1,454 @@
{
"schema": 1,
"version": 1,
"updated": "2026-07-12",
"signed_at": "2026-07-12T21:35:19Z",
"servers": [
{
"id": "filesystem",
"display": "Filesystem",
"description": "Lets Claude read, write, search, and edit files in directories you explicitly allow.",
"category": "files",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
"stars": 85995,
"official": true,
"setup": "basic",
"config": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem@2026.7.10",
"<ALLOWED_DIR>"
]
},
"placeholders": {
"<ALLOWED_DIR>": "Absolute path to a directory the server may read/write. Add more directories as additional args."
},
"env_required": {},
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
"notes": "Part of the official modelcontextprotocol/servers reference monorepo (star count is for the whole repo). Clients that support MCP 'roots' can also grant directories dynamically instead of via args.",
"last_release": "2026-07-10"
},
{
"id": "fetch",
"display": "Fetch",
"description": "Fetches a URL and converts the page to clean markdown so Claude can read web content.",
"category": "dev",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
"stars": 85995,
"official": true,
"setup": "basic",
"config": {
"command": "uvx",
"args": [
"mcp-server-fetch@2026.7.10"
]
},
"placeholders": {},
"env_required": {},
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
"notes": "Can access local/internal IPs, so treat as a mild security risk on untrusted networks. Add '--ignore-robots-txt' or '--user-agent=...' as extra args if needed.",
"last_release": "2026-07-10"
},
{
"id": "memory",
"display": "Memory",
"description": "Gives Claude a persistent knowledge-graph memory that survives across conversations.",
"category": "ai",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
"stars": 85995,
"official": true,
"setup": "basic",
"config": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-memory@2026.7.4"
]
},
"placeholders": {},
"env_required": {},
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
"notes": "Stores entities/relations as a local JSON knowledge graph (path configurable via MEMORY_FILE_PATH env var).",
"last_release": "2026-07-04"
},
{
"id": "sequential-thinking",
"display": "Sequential Thinking",
"description": "Gives Claude a structured, step-by-step reasoning tool for breaking down complex problems.",
"category": "ai",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
"stars": 85995,
"official": true,
"setup": "basic",
"config": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-sequential-thinking@2026.7.4"
]
},
"placeholders": {},
"env_required": {},
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
"notes": "Set DISABLE_THOUGHT_LOGGING=true to stop it from printing each thought step to the console.",
"last_release": "2026-07-04"
},
{
"id": "git",
"display": "Git",
"description": "Lets Claude read history, diff, branch, and search a local git repository.",
"category": "dev",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
"stars": 85995,
"official": true,
"setup": "basic",
"config": {
"command": "uvx",
"args": [
"mcp-server-git@2026.7.10",
"--repository",
"<REPO_PATH>"
]
},
"placeholders": {
"<REPO_PATH>": "Absolute path to the local git repository"
},
"env_required": {},
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
"notes": "Operates on a local checkout only; it does not talk to GitHub/GitLab APIs (pair with the GitHub or GitLab server for that).",
"last_release": "2026-07-10"
},
{
"id": "github",
"display": "GitHub",
"description": "Lets Claude read repos/code, and manage issues, pull requests, and Actions on GitHub.",
"category": "code-hosting",
"homepage": "https://github.com/github/github-mcp-server",
"stars": 30202,
"official": true,
"setup": "basic",
"config": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server:v1.0.1"
]
},
"placeholders": {},
"env_required": {
"GITHUB_PERSONAL_ACCESS_TOKEN": ""
},
"docs_url": "https://github.com/github/github-mcp-server/blob/main/docs/installation-guides/README.md",
"notes": "The old '@modelcontextprotocol/server-github' npm package is discontinued (deprecated April 2025). GitHub now ships a Docker-based local server (requires Docker installed/running) plus a hosted remote server at https://api.githubcopilot.com/mcp/ that supports OAuth or PAT auth without Docker."
},
{
"id": "playwright",
"display": "Playwright",
"description": "Lets Claude drive a real browser (click, type, navigate, screenshot) using Playwright's accessibility-tree snapshots.",
"category": "browser",
"homepage": "https://github.com/microsoft/playwright-mcp",
"stars": 34000,
"official": true,
"setup": "basic",
"config": {
"command": "npx",
"args": [
"@playwright/mcp@0.0.78"
]
},
"placeholders": {},
"env_required": {},
"docs_url": "https://github.com/microsoft/playwright-mcp#readme",
"notes": "Maintained by the Playwright team at Microsoft. Add '--isolated' for a throwaway profile, or '--browser firefox|webkit|msedge' to change engine. A persistent browser profile is used by default so logins carry over between sessions.",
"last_release": "2026-07-09"
},
{
"id": "chrome-devtools",
"display": "Chrome DevTools",
"description": "Lets Claude control Chrome and use real DevTools features: performance traces, network inspection, console logs, screenshots.",
"category": "browser",
"homepage": "https://github.com/ChromeDevTools/chrome-devtools-mcp",
"stars": 45000,
"official": true,
"setup": "basic",
"config": {
"command": "npx",
"args": [
"-y",
"chrome-devtools-mcp@1.5.0"
]
},
"placeholders": {},
"env_required": {},
"docs_url": "https://github.com/ChromeDevTools/chrome-devtools-mcp#readme",
"notes": "Maintained by the Google Chrome DevTools team; only officially supports Google Chrome / Chrome for Testing. Exposes the browser's content to the MCP client, so avoid sensitive sites while connected. Add '--slim --headless' for a minimal 3-tool basic-automation mode.",
"last_release": "2026-07-03"
},
{
"id": "postgres",
"display": "Postgres MCP Pro",
"description": "Lets Claude query, inspect schema, and analyze/tune performance of a PostgreSQL database.",
"category": "database",
"homepage": "https://github.com/crystaldba/postgres-mcp",
"stars": 2400,
"official": false,
"setup": "basic",
"config": {
"command": "uvx",
"args": [
"postgres-mcp@0.3.0",
"--access-mode=restricted"
]
},
"placeholders": {},
"env_required": {
"DATABASE_URI": ""
},
"docs_url": "https://github.com/crystaldba/postgres-mcp#readme",
"notes": "The official reference Postgres server was archived by the MCP team; this community server (Crystal DBA) is the most capable/most-referenced replacement, adding index tuning and EXPLAIN-plan analysis. Use --access-mode=restricted for read-only/production use. Docker image also available (crystaldba/postgres-mcp). Catalog ships --access-mode=restricted (read-only); switch to unrestricted yourself if you want writes.",
"last_release": "2025-05-16"
},
{
"id": "n8n",
"display": "n8n",
"description": "Build, validate, and deploy n8n workflows with full node documentation for the AI.",
"category": "infra",
"homepage": "https://github.com/czlonkowski/n8n-mcp",
"stars": 22257,
"official": false,
"setup": "basic",
"config": {
"command": "npx",
"args": [
"n8n-mcp@2.63.2"
]
},
"placeholders": {},
"env_required": {
"MCP_MODE": "",
"N8N_API_URL": "",
"N8N_API_KEY": ""
},
"docs_url": "https://github.com/czlonkowski/n8n-mcp",
"notes": "Set MCP_MODE=stdio (required for Claude Desktop, prevents debug logs from breaking the protocol). N8N_API_URL/N8N_API_KEY are optional — without them you still get full node documentation, validation, and template search; with them you get live workflow create/update/execute against your own n8n instance. A hosted free-tier alternative exists at dashboard.n8n-mcp.com.",
"last_release": "2026-07-09"
},
{
"id": "notion",
"display": "Notion",
"description": "Read, search, and edit Notion pages, databases, and comments from your AI assistant.",
"category": "productivity",
"homepage": "https://github.com/makenotion/notion-mcp-server",
"stars": 4400,
"official": true,
"setup": "basic",
"config": {
"command": "npx",
"args": [
"-y",
"@notionhq/notion-mcp-server@2.4.1"
]
},
"placeholders": {},
"env_required": {
"NOTION_TOKEN": ""
},
"docs_url": "https://developers.notion.com/docs/mcp",
"notes": "Notion is prioritizing its hosted remote MCP (OAuth, https://mcp.notion.com/mcp) and may eventually sunset this local package, but the stdio server still works today and is the simplest way to get a static config with an internal-integration token.",
"last_release": "2026-06-22"
},
{
"id": "obsidian",
"display": "Obsidian",
"description": "Read, search, and edit notes in your Obsidian vault.",
"category": "personal",
"homepage": "https://github.com/MarkusPfundstein/mcp-obsidian",
"stars": 4067,
"official": false,
"setup": "basic",
"config": {
"command": "uvx",
"args": [
"mcp-obsidian@0.2.2"
]
},
"placeholders": {},
"env_required": {
"OBSIDIAN_API_KEY": "",
"OBSIDIAN_HOST": "",
"OBSIDIAN_PORT": ""
},
"docs_url": "https://github.com/MarkusPfundstein/mcp-obsidian",
"notes": "Requires the Obsidian Local REST API community plugin installed and enabled in Obsidian; copy the API key from the plugin settings. OBSIDIAN_HOST defaults to 127.0.0.1 and OBSIDIAN_PORT to 27124 if omitted.",
"last_release": "2025-04-01"
},
{
"id": "brave-search",
"display": "Brave Search",
"description": "Search the web, news, images, and videos using Brave's independent search index.",
"category": "search",
"homepage": "https://github.com/brave/brave-search-mcp-server",
"stars": 1288,
"official": true,
"setup": "basic",
"config": {
"command": "npx",
"args": [
"-y",
"@brave/brave-search-mcp-server@2.0.85",
"--transport",
"stdio"
]
},
"placeholders": {},
"env_required": {
"BRAVE_API_KEY": ""
},
"docs_url": "https://github.com/brave/brave-search-mcp-server",
"notes": "Official Brave server; replaced the old archived modelcontextprotocol/servers brave-search entry (now in modelcontextprotocol/servers-archived). Get an API key from the Brave Search API dashboard.",
"last_release": "2026-06-15"
},
{
"id": "tavily",
"display": "Tavily",
"description": "AI-optimized web search, extract, map, and crawl API built for LLM agents.",
"category": "search",
"homepage": "https://github.com/tavily-ai/tavily-mcp",
"stars": 2206,
"official": true,
"setup": "basic",
"config": {
"command": "npx",
"args": [
"tavily-mcp@0.2.21"
]
},
"placeholders": {},
"env_required": {
"TAVILY_API_KEY": ""
},
"docs_url": "https://github.com/tavily-ai/tavily-mcp",
"notes": "Tavily also offers a hosted remote MCP endpoint (mcp.tavily.com) with OAuth as an alternative to running the local npx server.",
"last_release": "2026-07-10"
},
{
"id": "home-assistant",
"display": "Home Assistant",
"description": "Control smart home devices, query states, and troubleshoot automations in Home Assistant.",
"category": "smart-home",
"homepage": "https://github.com/voska/hass-mcp",
"stars": 308,
"official": false,
"setup": "basic",
"config": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"HA_URL",
"-e",
"HA_TOKEN",
"voska/hass-mcp:0.5.0"
]
},
"placeholders": {},
"env_required": {
"HA_URL": "",
"HA_TOKEN": ""
},
"docs_url": "https://github.com/voska/hass-mcp",
"notes": "HA_URL example: http://homeassistant.local:8123 (use http://host.docker.internal:8123 if HA runs in Docker on the same machine). HA_TOKEN is a Home Assistant long-lived access token from your profile page. A more actively developed alternative is the community 'HA-MCP' integration (homeassistant-ai/ha-mcp, ~3.9k stars), but it installs inside Home Assistant itself via HACS rather than as an external stdio process, so it doesn't fit this catalog's launch-line format."
},
{
"id": "kubernetes",
"display": "Kubernetes",
"description": "Lets Claude inspect and manage Kubernetes/OpenShift resources — pods, deployments, logs, Helm releases — using your local kubeconfig.",
"category": "infra",
"homepage": "https://github.com/containers/kubernetes-mcp-server",
"stars": 1626,
"official": false,
"setup": "basic",
"config": {
"command": "npx",
"args": [
"-y",
"kubernetes-mcp-server@0.0.64"
]
},
"placeholders": {},
"env_required": {},
"docs_url": "https://github.com/containers/kubernetes-mcp-server#readme",
"notes": "Not an official Kubernetes SIG project, but a Go-native (no kubectl dependency) implementation maintained under the 'containers' GitHub org (Podman/Red Hat-adjacent) that's widely regarded as the most capable K8s MCP server, supporting Kubernetes and OpenShift. Uses your existing ~/.kube/config automatically; add --read-only to prevent writes.",
"last_release": "2026-07-10"
},
{
"id": "aws-api-mcp-server",
"display": "AWS API MCP Server (AWS Labs)",
"description": "Lets your AI assistant run AWS CLI commands to inspect and manage AWS resources across virtually every AWS service.",
"category": "cloud",
"homepage": "https://github.com/awslabs/mcp",
"stars": 9431,
"official": true,
"setup": "basic",
"config": {
"command": "uvx",
"args": [
"awslabs.aws-api-mcp-server@1.3.46"
]
},
"placeholders": {},
"env_required": {},
"docs_url": "https://awslabs.github.io/mcp/servers/aws-api-mcp-server",
"notes": "AWS credentials are NOT set in this MCP config — configure them beforehand via `aws configure` (or set AWS_API_MCP_PROFILE_NAME to pick a named profile) so boto3's standard credential chain can find them. Optional env vars: AWS_REGION (default us-east-1), READ_OPERATIONS_ONLY=true to block all mutating AWS calls. AWS notes this server is being superseded by a newer unified AWS MCP server referenced in their agent-toolkit docs.",
"last_release": "2026-06-25"
},
{
"id": "grafana",
"display": "Grafana",
"description": "Query dashboards, datasources, alerts and incidents in Grafana from your AI assistant.",
"category": "observability",
"homepage": "https://github.com/grafana/mcp-grafana",
"stars": 3227,
"official": true,
"setup": "basic",
"config": {
"command": "uvx",
"args": [
"mcp-grafana@0.17.1"
],
"env": {
"GRAFANA_URL": "<GRAFANA_URL>"
}
},
"placeholders": {
"<GRAFANA_URL>": "Your Grafana instance URL, e.g. http://localhost:3000 or https://yourstack.grafana.net"
},
"env_required": {
"GRAFANA_SERVICE_ACCOUNT_TOKEN": ""
},
"docs_url": "https://grafana.com/docs/grafana/latest/developer-resources/mcp/",
"notes": "Requires Grafana 9.0+ for full functionality — datasource-related tools may not work correctly on older versions.",
"last_release": "2026-07-07"
},
{
"id": "slack",
"display": "Slack",
"description": "Search Slack messages, files, and members, and send messages or manage channels from your AI assistant.",
"category": "communication",
"homepage": "https://slack.com/help/articles/48855576908307-Guide-to-the-Slack-MCP-server",
"stars": null,
"official": true,
"setup": "link-only",
"env_required": {},
"docs_url": "https://docs.slack.dev/changelog/2026/02/17/slack-mcp/",
"notes": "Slack's own MCP server went GA Feb 17, 2026 (streamable HTTP at https://mcp.slack.com/mcp, OAuth). No stdio one-liner is published because it's a hosted, permissioned connector. A well-known community alternative, korotovsky/slack-mcp-server (~1.6k GitHub stars, MIT, not an official Slack product), supports stdio/SSE/HTTP with bot or browser-session tokens and no app-install requirement if a stdio option is preferred."
}
]
}
+2
View File
@@ -0,0 +1,2 @@
ы<8¶ђt2ішл„»‰/НЕ0Тjcw&`
тrH«MўК›єrBL,0AS€!Х2–иже.SТ°ч–'Agm
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 113 KiB

+58
View File
@@ -0,0 +1,58 @@
[project]
name = "better-claude-config"
version = "1.3.0"
description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
dependencies = [
"PySide6>=6.6",
"cryptography>=42.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.6",
"pre-commit>=3.5",
]
build = [
"pyinstaller>=6.0",
"pillow>=10.0",
]
[project.urls]
Repository = "https://git.avezzano.io/the_og/better-claude-config"
# ---------------------------------------------------------------------------
# Tooling
# ---------------------------------------------------------------------------
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"B", # bugbear
"SIM", # simplify
"RUF", # ruff-specific
]
ignore = [
"E501", # line length handled pragmatically; GUI strings run long
"SIM108", # ternary rewrites hurt readability in places
"RUF001", # UI strings intentionally use typographic glyphs (, →, ↳)
"RUF002",
"RUF003",
]
[tool.ruff.lint.per-file-ignores]
"bcc.py" = ["F405", "F403"] # Qt star-import style if ever used
+5
View File
@@ -4,3 +4,8 @@ PySide6>=6.6
# Build / packaging
pyinstaller>=6.0
pillow>=10.0 # generates icons/app.ico during CI (Windows build)
# Test / lint
pytest>=8.0
ruff>=0.6
cryptography>=42.0 # release checksum signing (scripts/sign_checksums.py)
+73 -14
View File
@@ -10,13 +10,11 @@ Requirements:
pip install pillow # for .ico
iconutil # built into macOS; for .icns
"""
from __future__ import annotations
import os
import platform
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
@@ -32,22 +30,83 @@ def build_ico():
print("Pillow not installed — skipping .ico generation (pip install pillow)")
return
sizes = [16, 32, 48, 64, 128, 256]
imgs = []
import io
import struct
sizes = [16, 24, 32, 48, 64, 128, 256]
images: list[tuple[int, object]] = []
for s in sizes:
p = SRC / f"icon-{s}.png"
if not p.exists():
print(f" Missing {p.name}, skipping")
continue
imgs.append(Image.open(p).convert("RGBA"))
images.append((s, Image.open(p).convert("RGBA")))
if not imgs:
if not images:
print(" No source PNGs found — cannot build .ico")
return
# Write the ICO manually so that entries ≤ 128 px use uncompressed BMP DIB
# and the 256 px entry uses PNG. Pillow's ICO saver stores all sizes as
# PNG-compressed ("Vista icon" format), which PyInstaller's Windows
# resource-updater cannot embed — it silently falls back to its default icon.
def bmp_dib(img: object) -> bytes:
"""Return a BMP DIB (BITMAPINFOHEADER + BGRA rows + AND mask)."""
w, h = img.size
# biHeight is doubled: top half = XOR mask (color), bottom = AND mask
hdr = struct.pack(
"<IiiHHIIiiII",
40, # biSize
w,
h * 2, # biHeight (doubled per ICO convention)
1, # biPlanes
32, # biBitCount
0, # biCompression (BI_RGB)
0, # biSizeImage (0 ok for BI_RGB)
0,
0,
0,
0,
)
pix = img.load()
rows = bytearray()
for y in range(h - 1, -1, -1): # bottom-up
for x in range(w):
r, g, b, a = pix[x, y]
rows += bytes([b, g, r, a])
# AND mask: 1 bit/pixel, rows padded to 32-bit boundary, all 0 (use alpha)
mask_row = ((w + 31) // 32) * 4
and_mask = bytes(mask_row * h)
return hdr + bytes(rows) + and_mask
def png_bytes(img: object) -> bytes:
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
blobs: list[tuple[int, bytes]] = []
for s, img in images:
blobs.append((s, bmp_dib(img) if s < 256 else png_bytes(img)))
n = len(blobs)
dir_offset = 6 + n * 16 # ICONDIR (6) + n × ICONDIRENTRY (16)
out = bytearray()
out += struct.pack("<HHH", 0, 1, n) # ICONDIR
cur = dir_offset
for s, blob in blobs:
w = h = s % 256 # 256 is stored as 0 in the byte field
out += struct.pack("<BBBBHHII", w, h, 0, 0, 1, 32, len(blob), cur)
cur += len(blob)
for _, blob in blobs:
out += blob
dest = OUT / "app.ico"
imgs[-1].save(dest, format="ICO", append_images=imgs[:-1])
print(f" Generated {dest.relative_to(ROOT)} ({dest.stat().st_size // 1024 + 1} KB)")
dest.write_bytes(out)
print(f" Generated {dest.relative_to(ROOT)} ({len(out) // 1024 + 1} KB)")
def build_icns():
@@ -58,15 +117,15 @@ def build_icns():
iconset = Path(tempfile.mkdtemp()) / "app.iconset"
iconset.mkdir()
mapping = {
"icon_16x16.png": "icon-16.png",
"icon_16x16.png": "icon-16.png",
"icon_16x16@2x.png": "icon-32.png",
"icon_32x32.png": "icon-32.png",
"icon_32x32.png": "icon-32.png",
"icon_32x32@2x.png": "icon-64.png",
"icon_128x128.png": "icon-128.png",
"icon_128x128.png": "icon-128.png",
"icon_128x128@2x.png": "icon-256.png",
"icon_256x256.png": "icon-256.png",
"icon_256x256.png": "icon-256.png",
"icon_256x256@2x.png": "icon-512.png",
"icon_512x512.png": "icon-512.png",
"icon_512x512.png": "icon-512.png",
"icon_512x512@2x.png": "icon-1024.png",
}
for dst_name, src_name in mapping.items():
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""
Generate a SHA256SUMS file for release artifacts and sign it with Ed25519.
BCC ships PyInstaller binaries that are not code-signed (no budget for a
macOS Developer ID / Windows Authenticode certificate). This script provides
the free half of supply-chain integrity: a checksum manifest, detached-signed
so downloaders can verify the file they got is the file we published.
This does NOT remove Gatekeeper/SmartScreen warnings and does NOT prove the
binary is safe to run -- only that it matches what the release signing key
attested to.
Usage:
# Hash every file in a directory into a SHA256SUMS-format manifest.
python scripts/sign_checksums.py generate <dir> --out SHA256SUMS
# Sign a manifest, producing a detached signature.
# Private key comes from $RELEASE_SIGNING_KEY (base64 raw Ed25519 seed)
# unless --key-b64 is given explicitly (mostly for tests).
python scripts/sign_checksums.py sign --sums SHA256SUMS --out SHA256SUMS.sig
# Verify a manifest against a detached signature and a public key.
python scripts/sign_checksums.py verify --sums SHA256SUMS --sig SHA256SUMS.sig \
--pubkey-b64 <base64 raw Ed25519 public key>
The private key is generated and rotated via the Catalog Console (#62) --
this script never generates or stores a key itself.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import os
import sys
from pathlib import Path
# Domain separation prefix: ties every signature to "a BCC release checksum
# manifest" so a signature can never be replayed against an unrelated
# message signed by the same key.
DOMAIN_PREFIX = b"bcc-release-v1|"
# Public half of the RELEASE signing key(s) -- a SEPARATE keypair from
# bcc_core.CATALOG_PUBKEYS (issue #68 finding 5). The catalog key is the
# offline, Console-only root of trust for what BCC executes; this key is
# CI-resident and signs ONLY the release SHA256SUMS manifest, never the
# catalog. Keeping them apart means a CI/repo-secret compromise burns the
# release key -- annoying, but it never lets an attacker sign a catalog a
# user's binary would trust. A LIST (not a single key), mirroring
# CATALOG_PUBKEYS, so the release key can be rotated without invalidating
# the signature on every past release: verification accepts a match against
# ANY key here.
#
# 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
# This is intentionally NOT pre-populated with a placeholder that looks
# like a real key -- release.yml's signing-smoke-test fails closed (loudly)
# on an empty list rather than silently verifying against nothing.
RELEASE_PUBKEYS: list[bytes] = []
CHUNK_SIZE = 1024 * 1024
def sha256_file(path: Path) -> str:
"""Return the lowercase hex SHA-256 digest of a file's contents."""
digest = hashlib.sha256()
with open(path, "rb") as fh:
while chunk := fh.read(CHUNK_SIZE):
digest.update(chunk)
return digest.hexdigest()
def build_checksums_text(files: dict[str, str]) -> str:
"""Build a sha256sum(1)-compatible manifest body.
`files` maps filename -> hex digest. Entries are sorted by filename for
a deterministic, diffable output. Format matches `sha256sum` exactly:
"<hash> <filename>\n" (two spaces, no path components).
"""
lines = [f"{digest} {name}" for name, digest in sorted(files.items())]
body = "\n".join(lines)
return body + "\n" if body else ""
def generate_checksums(directory: Path, *, exclude: set[str] | None = None) -> str:
"""Hash every regular file directly inside `directory` (non-recursive)
and return the SHA256SUMS text. Filenames are recorded without any
directory prefix so the manifest can be verified from inside the
directory it describes.
"""
exclude = exclude or set()
files: dict[str, str] = {}
for entry in sorted(directory.iterdir()):
if not entry.is_file():
continue
if entry.name in exclude:
continue
files[entry.name] = sha256_file(entry)
return build_checksums_text(files)
def _signing_message(sums_text: str) -> bytes:
"""The exact bytes that get signed: the domain prefix followed by the
raw bytes of the SHA256SUMS file content."""
return DOMAIN_PREFIX + sums_text.encode("utf-8")
def sign_checksums(seed_b64: str, sums_text: str) -> bytes:
"""Sign `sums_text` with the Ed25519 private key encoded (base64, raw
32-byte seed) in `seed_b64`. Returns the raw 64-byte signature."""
# Imported lazily so `generate` mode (used on every CI run) never
# requires the `cryptography` package to be installed.
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
seed = base64.b64decode(seed_b64)
if len(seed) != 32:
raise ValueError(f"expected a 32-byte raw Ed25519 seed, got {len(seed)} bytes")
private_key = Ed25519PrivateKey.from_private_bytes(seed)
return private_key.sign(_signing_message(sums_text))
def verify_checksums(pubkey_b64: str, sums_text: str, signature: bytes) -> bool:
"""Verify `signature` over `sums_text` against the base64-encoded raw
32-byte Ed25519 public key. Returns True/False; never raises for a bad
signature (only for malformed inputs)."""
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
pubkey_bytes = base64.b64decode(pubkey_b64)
if len(pubkey_bytes) != 32:
raise ValueError(
f"expected a 32-byte raw Ed25519 public key, got {len(pubkey_bytes)} bytes"
)
public_key = Ed25519PublicKey.from_public_bytes(pubkey_bytes)
try:
public_key.verify(signature, _signing_message(sums_text))
return True
except InvalidSignature:
return False
def public_key_b64_from_seed(seed_b64: str) -> str:
"""Derive the base64 raw public key from a base64 raw seed. Handy for
local key-pair sanity checks; not used by the release workflow."""
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
seed = base64.b64decode(seed_b64)
private_key = Ed25519PrivateKey.from_private_bytes(seed)
raw = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
return base64.b64encode(raw).decode("ascii")
def verify_checksums_against_any(pubkeys: list[bytes], sums_text: str, signature: bytes) -> bool:
"""Verify `signature` against ANY key in `pubkeys` (each a raw 32-byte
Ed25519 public key). Mirrors bcc_core.verify_catalog_signature's
rotation-friendly "any currently-trusted key" semantics, applied to
RELEASE_PUBKEYS instead of the catalog's key list. Returns False (never
raises) for an empty `pubkeys` list -- fails closed rather than
vacuously verifying against nothing."""
return any(
verify_checksums(base64.b64encode(pk).decode("ascii"), sums_text, signature)
for pk in pubkeys
)
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def _cmd_generate(args: argparse.Namespace) -> int:
directory = Path(args.directory)
exclude = {"SHA256SUMS", "SHA256SUMS.sig"}
text = generate_checksums(directory, exclude=exclude)
out_path = Path(args.out)
out_path.write_text(text, encoding="utf-8")
print(f"Wrote {out_path} ({len(text.splitlines())} entries)")
return 0
def _cmd_sign(args: argparse.Namespace) -> int:
seed_b64 = args.key_b64 or os.environ.get(args.key_env, "")
if not seed_b64:
print(
f"error: no signing key provided (checked --key-b64 and ${args.key_env})",
file=sys.stderr,
)
return 1
sums_text = Path(args.sums).read_text(encoding="utf-8")
signature = sign_checksums(seed_b64, sums_text)
Path(args.out).write_bytes(signature)
print(f"Wrote {args.out} ({len(signature)} bytes)")
return 0
def _cmd_verify(args: argparse.Namespace) -> int:
pubkey_b64 = args.pubkey_b64 or os.environ.get(args.pubkey_env, "")
if not pubkey_b64:
print(
f"error: no public key provided (checked --pubkey-b64 and ${args.pubkey_env})",
file=sys.stderr,
)
return 1
sums_text = Path(args.sums).read_text(encoding="utf-8")
signature = Path(args.sig).read_bytes()
ok = verify_checksums(pubkey_b64, sums_text, signature)
if ok:
print("OK: signature is valid")
return 0
print("FAILED: signature is invalid", file=sys.stderr)
return 1
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
sub = parser.add_subparsers(dest="mode", required=True)
p_gen = sub.add_parser(
"generate", help="hash every file in a directory into a SHA256SUMS manifest"
)
p_gen.add_argument("directory", help="directory whose files should be hashed (non-recursive)")
p_gen.add_argument("--out", required=True, help="path to write the SHA256SUMS manifest to")
p_gen.set_defaults(func=_cmd_generate)
p_sign = sub.add_parser("sign", help="detached-sign a SHA256SUMS manifest with Ed25519")
p_sign.add_argument("--sums", required=True, help="path to the SHA256SUMS manifest to sign")
p_sign.add_argument("--out", required=True, help="path to write the detached signature to")
p_sign.add_argument(
"--key-b64", default=None, help="base64 raw Ed25519 seed (overrides --key-env)"
)
p_sign.add_argument(
"--key-env",
default="RELEASE_SIGNING_KEY",
help="environment variable holding the base64 seed (default: RELEASE_SIGNING_KEY)",
)
p_sign.set_defaults(func=_cmd_sign)
p_verify = sub.add_parser(
"verify", help="verify a SHA256SUMS manifest against a detached signature"
)
p_verify.add_argument("--sums", required=True, help="path to the SHA256SUMS manifest")
p_verify.add_argument("--sig", required=True, help="path to the detached signature")
p_verify.add_argument(
"--pubkey-b64", default=None, help="base64 raw Ed25519 public key (overrides --pubkey-env)"
)
p_verify.add_argument(
"--pubkey-env",
default="RELEASE_SIGNING_PUBKEY",
help="environment variable holding the base64 public key (default: RELEASE_SIGNING_PUBKEY)",
)
p_verify.set_defaults(func=_cmd_verify)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
-92
View File
@@ -1,92 +0,0 @@
import json, tempfile, shutil
from pathlib import Path
import bcc_core as c
tmp = Path(tempfile.mkdtemp())
ok = []
def check(name, cond):
ok.append(cond)
print(("PASS" if cond else "FAIL"), "-", name)
# ---- 1. discovery (monkeypatch the base dir) ----
base = tmp / "AppSupport"
for d in ("Claude", "Claude-Work", "NotClaude"):
(base / d).mkdir(parents=True)
(base / "Claude" / c.CONFIG_FILENAME).write_text("{}")
# Claude-Work has no config yet; NotClaude shouldn't match
c.app_support_base = lambda: base
profs = c.discover_profiles()
labels = sorted(p.label for p in profs)
check("discovers Claude + Claude-Work, not NotClaude",
"Claude" in labels and "Claude-Work" in labels and "NotClaude" not in labels)
check("flags missing config file", any(p.label=="Claude-Work" and not p.config_exists for p in profs))
# ---- 2. write preserves OTHER keys + order, only touches mcpServers ----
cfgpath = tmp / "real.json"
original = {
"globalShortcut": "Cmd+Shift+Space",
"mcpServers": {"old": {"command": "node", "args": ["x.js"]}},
"someOtherTool": {"keep": True},
}
cfgpath.write_text(json.dumps(original, indent=2))
cfg = c.load_config(cfgpath)
servers = c.extract_servers(cfg)
# add a new one, disable the old one
servers.append(c.ServerEntry("brave", {"command": "npx", "args": ["-y", "@x/brave"]}, True))
servers[0].enabled = False
c.apply_servers(cfg, servers)
c.write_config(cfgpath, cfg)
written = json.loads(cfgpath.read_text())
keys = list(written.keys())
check("preserved globalShortcut", written.get("globalShortcut") == "Cmd+Shift+Space")
check("preserved someOtherTool", written.get("someOtherTool") == {"keep": True})
check("disabled server parked in _disabledMcpServers", "old" in written.get("_disabledMcpServers", {}))
check("enabled server in mcpServers", "brave" in written.get("mcpServers", {}))
check("old not in active mcpServers", "old" not in written.get("mcpServers", {}))
check("key order kept (globalShortcut before mcpServers)", keys.index("globalShortcut") < keys.index("mcpServers"))
check("backup created", (cfgpath.parent / c.BACKUP_DIRNAME).is_dir() and any((cfgpath.parent / c.BACKUP_DIRNAME).iterdir()))
# ---- 3. paste parser, all shapes ----
full = '{"mcpServers": {"a": {"command": "node", "args": ["a.js"]}}}'
check("parse full config shape", list(c.parse_pasted_json(full)) == ["a"])
inner = '{"b": {"command": "uvx", "args": ["mcp-server-git"]}}'
check("parse inner block shape", list(c.parse_pasted_json(inner)) == ["b"])
bare = '{"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]}'
parsed = c.parse_pasted_json(bare)
check("parse bare server + suggests name 'filesystem'", "filesystem" in parsed)
remote = '{"url": "https://mcp.asana.com/sse"}'
check("parse remote, suggests host name", "mcp" in c.parse_pasted_json(remote))
try:
c.parse_pasted_json('{"junk": 5}')
check("rejects junk", False)
except ValueError:
check("rejects junk", True)
# ---- 4. validation ----
bad = [c.ServerEntry("", {"command": "x"}, True),
c.ServerEntry("dup", {"command": "x"}, True),
c.ServerEntry("dup", {"command": "x"}, True),
c.ServerEntry("r", {"url": ""}, True),
c.ServerEntry("nocmd", {"command": ""}, True)]
probs = c.validate_servers(bad)
check("validation catches empty name", any("empty name" in p for p in probs))
check("validation catches duplicate", any("Duplicate" in p for p in probs))
check("validation catches missing url", any("no URL" in p for p in probs))
check("validation catches missing command", any("no command" in p for p in probs))
check("valid set passes clean", c.validate_servers([c.ServerEntry("good", {"command":"node"}, True)]) == [])
# ---- 5. dependency check (python3 exists; bogusxyz does not) ----
r_ok = c.check_dependency({"command": "python3"})
check("dep check finds python3", r_ok["status"] == "ok")
r_missing = c.check_dependency({"command": "definitely-not-real-binary-xyz"})
check("dep check flags missing", r_missing["status"] == "missing")
r_remote = c.check_dependency({"url": "https://example.com/mcp"})
check("dep check marks remote", r_remote["status"] == "remote")
# windows-style wrapper: cmd /c npx ... -> should look past cmd to npx
r_wrap = c.check_dependency({"command": "cmd", "args": ["/c", "definitely-not-real-xyz", "foo"]})
check("dep check sees through shell wrapper", "definitely-not-real-xyz" in r_wrap["label"])
print()
print(f"{sum(ok)}/{len(ok)} passed")
shutil.rmtree(tmp)
+66
View File
@@ -0,0 +1,66 @@
"""Asserts the maintainer-only Catalog Console (catalog_console.py,
catalog_review.py) is never bundled into the release binary.
A signing/review tool shipping to end users would be an own-goal (issue
#62): it has no reason to run on a user's machine, and its presence would
be a confusing artefact of a build that's supposed to be a thin GUI over
mcpServers config editing."""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SPEC_PATH = REPO_ROOT / "bcc.spec"
_EXCLUDED_FILES = ("catalog_console.py", "catalog_review.py")
def test_spec_file_exists():
assert SPEC_PATH.exists()
def test_console_files_not_named_in_spec():
"""The spec text must never reference either maintainer-only module --
not as the Analysis entry point, not in datas, not anywhere."""
spec_text = SPEC_PATH.read_text(encoding="utf-8")
for filename in _EXCLUDED_FILES:
assert filename not in spec_text, (
f"{filename} must never be referenced by bcc.spec -- it is a "
"maintainer-only tool and must not ship to users."
)
def test_spec_analysis_entry_point_is_bcc_py_only():
"""PyInstaller's Analysis(...) call determines the dependency-scanned
entry point(s); it must be bcc.py alone."""
spec_text = SPEC_PATH.read_text(encoding="utf-8")
assert 'Analysis(\n ["bcc.py"],' in spec_text or 'Analysis(["bcc.py"]' in spec_text, (
"bcc.spec's Analysis(...) entry point changed shape -- re-verify by hand "
"that catalog_console.py / catalog_review.py are still excluded."
)
def test_console_modules_exist_but_are_standalone_top_level_files():
"""Sanity check the files this test is guarding actually exist as
top-level modules (not, say, silently moved into a package PyInstaller's
Analysis would still pick up as an implicit import of bcc.py)."""
for filename in _EXCLUDED_FILES:
assert (REPO_ROOT / filename).exists()
# bcc.py must not import them.
bcc_text = (REPO_ROOT / "bcc.py").read_text(encoding="utf-8")
module_name = filename.removesuffix(".py")
assert f"import {module_name}" not in bcc_text
assert f"from {module_name}" not in bcc_text
def test_requirements_files_do_not_reference_console_only_needs():
"""catalog_console.py's only import beyond the shipped stack is the
optional `keyring` package, which is intentionally NOT added as a hard
dependency anywhere a user install would pick it up."""
for req_file in ("requirements.txt", "requirements-dev.txt"):
path = REPO_ROOT / req_file
if not path.exists():
continue
text = path.read_text(encoding="utf-8").lower()
assert "keyring" not in text
+695
View File
@@ -0,0 +1,695 @@
"""Tests for catalog_review.py -- semantic diff, risk predicates, review
session (acknowledge-gating + TOCTOU blob pinning), key encryption, and
registry-lookup logic for the Catalog Console (issue #62)."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import bcc_core as c
import catalog_review as r
def _entry(**overrides):
base = {
"id": "filesystem",
"display": "Filesystem",
"description": "desc",
"category": "files",
"homepage": "https://github.com/modelcontextprotocol/servers",
"stars": 100,
"official": True,
"setup": "basic",
"config": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem@1.0.0"],
},
"env_required": {},
"docs_url": "https://github.com/modelcontextprotocol/servers",
"notes": "",
"last_release": "2026-01-01",
}
base.update(overrides)
return base
def _catalog(*entries):
return {"schema": 1, "version": 1, "servers": list(entries)}
# --------------------------------------------------------------------------- #
# diff_catalogs
# --------------------------------------------------------------------------- #
def test_diff_detects_added_entry():
old = _catalog()
new = _catalog(_entry())
changes = r.diff_catalogs(old, new)
assert len(changes) == 1
assert changes[0].status == "added"
assert changes[0].entry_id == "filesystem"
assert changes[0].old is None
def test_diff_detects_removed_entry():
old = _catalog(_entry())
new = _catalog()
changes = r.diff_catalogs(old, new)
assert len(changes) == 1
assert changes[0].status == "removed"
assert changes[0].new is None
def test_diff_detects_no_change():
e = _entry()
old = _catalog(e)
new = _catalog(dict(e))
assert r.diff_catalogs(old, new) == []
def test_diff_detects_changed_command_and_args():
old = _catalog(_entry())
new = _catalog(_entry(config={"command": "uvx", "args": ["other-pkg@2.0.0"]}))
changes = r.diff_catalogs(old, new)
assert len(changes) == 1
ch = changes[0]
assert ch.status == "changed"
fields = {fc.field for fc in ch.field_changes}
assert "config.command" in fields
assert "config.args" in fields
def test_diff_detects_description_change():
old = _catalog(_entry())
new = _catalog(_entry(description="new description"))
changes = r.diff_catalogs(old, new)
assert changes[0].field_changes == (r.FieldChange("description", "desc", "new description"),)
def test_diff_ignores_entries_without_id():
old = _catalog()
new = _catalog({"display": "no id"})
assert r.diff_catalogs(old, new) == []
def test_diff_multiple_entries_sorted_by_id():
old = _catalog(_entry(id="zeta"), _entry(id="alpha"))
new = _catalog(
_entry(id="zeta", description="changed"), _entry(id="alpha", description="changed")
)
changes = r.diff_catalogs(old, new)
assert [c_.entry_id for c_ in changes] == ["alpha", "zeta"]
# --------------------------------------------------------------------------- #
# risk_env_required
# --------------------------------------------------------------------------- #
def test_risk_env_required_blocking_on_nonempty_value():
change = r.EntryChange("x", "changed", None, _entry(env_required={"API_KEY": "sk-real-value"}))
findings = r.risk_env_required(change)
assert len(findings) == 1
assert findings[0].severity == "blocking"
assert findings[0].code == "env_required_value"
def test_risk_env_required_clean_on_empty_value():
change = r.EntryChange("x", "changed", None, _entry(env_required={"API_KEY": ""}))
assert r.risk_env_required(change) == []
# --------------------------------------------------------------------------- #
# risk_command_allowlist
# --------------------------------------------------------------------------- #
def test_risk_command_allowlist_blocks_disallowed_command():
change = r.EntryChange(
"x", "changed", None, _entry(config={"command": "bash", "args": ["-c", "evil"]})
)
findings = r.risk_command_allowlist(change)
assert len(findings) == 1
assert findings[0].severity == "blocking"
def test_risk_command_allowlist_allows_listed_command():
for cmd in sorted(c.CATALOG_ALLOWED_COMMANDS):
change = r.EntryChange("x", "changed", None, _entry(config={"command": cmd, "args": []}))
assert r.risk_command_allowlist(change) == []
# --------------------------------------------------------------------------- #
# risk_non_ascii
# --------------------------------------------------------------------------- #
def test_risk_non_ascii_flags_homoglyph_id():
# Cyrillic 'а' (U+0430) instead of Latin 'a' -- classic homoglyph swap.
evil_id = "filаsystem"
change = r.EntryChange(evil_id, "changed", None, _entry(id=evil_id))
findings = r.risk_non_ascii(change)
assert any(f.code == "non_ascii_id" for f in findings)
assert findings[0].severity == "blocking"
# the offending string must be rendered with escapes, not raw
assert "\\u0430" in findings[0].message
def test_risk_non_ascii_flags_arg():
change = r.EntryChange(
"x", "changed", None, _entry(config={"command": "npx", "args": ["pаckage@1.0.0"]})
)
findings = r.risk_non_ascii(change)
assert any(f.code == "non_ascii_arg" for f in findings)
def test_risk_non_ascii_clean_for_ascii_entry():
change = r.EntryChange("x", "changed", None, _entry())
assert r.risk_non_ascii(change) == []
# --------------------------------------------------------------------------- #
# risk_unpinned_package
# --------------------------------------------------------------------------- #
def test_risk_unpinned_npm_package_no_version():
change = r.EntryChange(
"x",
"changed",
None,
_entry(config={"command": "npx", "args": ["-y", "@scope/pkg"]}),
)
findings = r.risk_unpinned_package(change)
assert len(findings) == 1
assert findings[0].code == "unpinned_npm_package"
assert findings[0].severity == "blocking"
def test_risk_pinned_npm_package_is_clean():
change = r.EntryChange(
"x",
"changed",
None,
_entry(config={"command": "npx", "args": ["-y", "@scope/pkg@1.2.3"]}),
)
assert r.risk_unpinned_package(change) == []
def test_risk_unpinned_unscoped_npm_package():
change = r.EntryChange(
"x", "changed", None, _entry(config={"command": "npx", "args": ["-y", "somepkg"]})
)
findings = r.risk_unpinned_package(change)
assert len(findings) == 1
def test_risk_unpinned_docker_latest_tag():
change = r.EntryChange(
"x",
"changed",
None,
_entry(config={"command": "docker", "args": ["run", "-i", "--rm", "myimage:latest"]}),
)
findings = r.risk_unpinned_package(change)
assert len(findings) == 1
assert findings[0].code == "unpinned_docker_image"
def test_risk_unpinned_docker_no_tag():
change = r.EntryChange(
"x", "changed", None, _entry(config={"command": "docker", "args": ["run", "myimage"]})
)
findings = r.risk_unpinned_package(change)
assert len(findings) == 1
def test_risk_pinned_docker_image_is_clean():
change = r.EntryChange(
"x",
"changed",
None,
_entry(config={"command": "docker", "args": ["run", "-i", "--rm", "myimage:1.2.3"]}),
)
assert r.risk_unpinned_package(change) == []
def test_risk_docker_digest_pin_is_clean():
change = r.EntryChange(
"x",
"changed",
None,
_entry(
config={
"command": "docker",
"args": ["run", "myimage@sha256:" + "a" * 64],
}
),
)
assert r.risk_unpinned_package(change) == []
# --------------------------------------------------------------------------- #
# risk_url_domain_change
# --------------------------------------------------------------------------- #
def test_risk_url_domain_change_warns_on_lookalike_swap():
old_entry = _entry(homepage="https://github.com/foo/bar")
new_entry = _entry(homepage="https://githu6.com/foo/bar")
change = r.EntryChange("x", "changed", old_entry, new_entry)
findings = r.risk_url_domain_change(change)
assert any(f.code == "domain_changed" for f in findings)
domain_finding = next(f for f in findings if f.code == "domain_changed")
assert "github.com" in domain_finding.message
assert "githu6.com" in domain_finding.message
assert domain_finding.severity == "warning"
def test_risk_url_domain_change_clean_when_domain_unchanged():
old_entry = _entry(homepage="https://github.com/foo/bar")
new_entry = _entry(homepage="https://github.com/foo/bar-renamed")
change = r.EntryChange("x", "changed", old_entry, new_entry)
assert r.risk_url_domain_change(change) == []
def test_risk_url_non_https_warns():
new_entry = _entry(homepage="http://example.com")
change = r.EntryChange("x", "changed", None, new_entry)
findings = r.risk_url_domain_change(change)
assert any(f.code == "non_https_url" for f in findings)
# --------------------------------------------------------------------------- #
# risk_new_entry / entry_risk_findings / has_blocking_risk
# --------------------------------------------------------------------------- #
def test_risk_new_entry_flags_added():
change = r.EntryChange("x", "added", None, _entry())
findings = r.risk_new_entry(change)
assert len(findings) == 1
assert findings[0].severity == "info"
def test_risk_new_entry_silent_for_changed():
change = r.EntryChange("x", "changed", _entry(), _entry(description="x"))
assert r.risk_new_entry(change) == []
def test_has_blocking_risk_true_for_disallowed_command():
change = r.EntryChange("x", "changed", None, _entry(config={"command": "bash", "args": []}))
assert r.has_blocking_risk(change) is True
def test_has_blocking_risk_false_for_clean_entry():
change = r.EntryChange("x", "changed", _entry(), _entry(description="new"))
assert r.has_blocking_risk(change) is False
# --------------------------------------------------------------------------- #
# ReviewSession: acknowledge gating
# --------------------------------------------------------------------------- #
def test_start_review_computes_diff():
old = _catalog()
new = _catalog(_entry())
session = r.start_review("sha1", old, new)
assert len(session.changes) == 1
def test_all_entries_acknowledged_false_initially():
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
assert r.all_entries_acknowledged(session) is False
def test_acknowledge_entry_marks_acknowledged():
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
r.acknowledge_entry(session, "filesystem")
assert r.all_entries_acknowledged(session) is True
def test_acknowledge_unknown_entry_raises():
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
with pytest.raises(ValueError):
r.acknowledge_entry(session, "not-in-diff")
def test_acknowledge_gating_requires_every_entry():
session = r.start_review("sha1", _catalog(), _catalog(_entry(id="a"), _entry(id="b")))
r.acknowledge_entry(session, "a")
assert r.all_entries_acknowledged(session) is False
r.acknowledge_entry(session, "b")
assert r.all_entries_acknowledged(session) is True
def test_no_acknowledge_all_shortcut_and_gate_is_real():
"""Two things, both load-bearing (issue #68: the original version of
this test asserted ONLY the first half, and passed the entire time the
gate below it was vacuously satisfiable -- 'no function named
acknowledge_all' is worthless if signing doesn't actually require
acknowledgement in practice).
1. No bulk-acknowledge shortcut exists (see the comment in
catalog_review.py above SignDecision -- deliberate friction).
2. The gate that friction protects is actually enforced: with entries
still unacknowledged, can_sign() must refuse, not just "some GUI
checkbox happens to be unticked".
"""
names = [n for n in dir(r) if "acknowledge" in n.lower()]
assert "acknowledge_all" not in names
assert "acknowledge_all_entries" not in names
session = r.start_review("sha1", _catalog(), _catalog(_entry(id="a"), _entry(id="b")))
r.acknowledge_entry(session, "a") # only one of two -- not a bulk call
decision = r.can_sign(session, "sha1")
assert decision.ok is False
assert "acknowledged" in decision.reason.lower()
# --------------------------------------------------------------------------- #
# can_sign: TOCTOU blob pinning + acknowledge gating combined
# --------------------------------------------------------------------------- #
def test_can_sign_false_when_not_all_acknowledged():
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
decision = r.can_sign(session, "sha1")
assert decision.ok is False
assert "acknowledged" in decision.reason
def test_can_sign_true_when_acknowledged_and_blob_matches():
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
r.acknowledge_entry(session, "filesystem")
decision = r.can_sign(session, "sha1")
assert decision.ok is True
assert decision.reason is None
def test_can_sign_refuses_on_blob_mismatch_even_if_acknowledged():
"""The core TOCTOU fix: acknowledging everything is not enough if the
bytes on the remote changed underneath the review."""
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
r.acknowledge_entry(session, "filesystem")
decision = r.can_sign(session, "sha2-a-new-commit-landed")
assert decision.ok is False
assert "changed" in decision.reason.lower() or "mismatch" in decision.reason.lower()
def test_can_sign_blob_mismatch_takes_priority_message():
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
decision = r.can_sign(session, "sha2")
assert decision.ok is False
assert "blob" in decision.reason.lower() or "changed" in decision.reason.lower()
def test_can_sign_false_on_empty_changeset():
"""The exact bug behind issue #68 finding 1: 'main' loaded against
itself diffs to [], and an empty changeset used to leave can_sign()
with nothing to refuse on (set() <= set() is vacuously True). Commit
b08cf21 signed 19 entries through precisely this path -- zero of them
were ever reviewed. An empty diff must mean 'nothing to sign', never
'sign unlocked'."""
same_catalog = _catalog(_entry())
session = r.start_review("sha1", same_catalog, same_catalog)
assert session.changes == [] # diff_catalogs(x, x) -> []
assert r.all_entries_acknowledged(session) is True # vacuously -- this is the trap
decision = r.can_sign(session, "sha1") # blob matches, "everything" acknowledged
assert decision.ok is False
assert "nothing to sign" in decision.reason.lower()
def test_can_sign_false_with_outstanding_blocking_risk_even_if_acknowledged():
"""can_sign() must itself refuse a blocking risk finding -- today a
blocking finding only disables the GUI checkbox, so the pure gate must
not simply trust that the caller never acknowledged a blocking entry.
Acknowledge it directly here (bypassing any GUI checkbox-disable logic
entirely) to prove the gate catches it independently of the GUI."""
session = r.start_review(
"sha1",
_catalog(),
_catalog(_entry(config={"command": "bash", "args": ["-c", "evil"]})),
)
r.acknowledge_entry(session, "filesystem")
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()
# --------------------------------------------------------------------------- #
# sign_precondition: the ref-resolution seam that used to hardcode "main"
# --------------------------------------------------------------------------- #
def test_sign_precondition_resolves_against_loaded_ref_not_hardcoded_main():
"""The regression test for issue #68 finding 1's first bug:
ReviewWindow._on_sign used to hardcode fetch_ref(repo, "main") as the
TOCTOU comparison ref. For a PR review, _on_load pins the PR HEAD's
blob SHA, so comparing against main's SHA differs by definition and
Sign could never fire on the PR path.
The fake resolver below returns a DIFFERENT (deliberately wrong) SHA for
"main" than for the PR ref that was actually loaded. If
sign_precondition ever resolves against "main" instead of
session.loaded_ref, this test fails -- both via the recorded `calls`
list and via decision.ok flipping to False.
"""
pr_ref = "refs/pull/42/head"
session = r.start_review("pr-blob-sha", _catalog(), _catalog(_entry()), loaded_ref=pr_ref)
r.acknowledge_entry(session, "filesystem")
calls: list[str] = []
def fake_resolver(ref: str) -> str:
calls.append(ref)
return {"main": "main-blob-sha-WRONG", pr_ref: "pr-blob-sha"}[ref]
decision = r.sign_precondition(session, fake_resolver)
assert calls == [pr_ref] # never asked the resolver for "main"
assert decision.ok is True
assert decision.reason is None
def test_sign_precondition_refuses_when_loaded_ref_blob_moved():
"""Same seam, the negative case: if the loaded ref's blob SHA has moved
since review began (a new commit landed on the reviewed PR/branch), the
resolver reflects that and sign_precondition must refuse -- proving this
isn't just a hardcoded pass-through."""
pr_ref = "refs/pull/42/head"
session = r.start_review("pr-blob-sha", _catalog(), _catalog(_entry()), loaded_ref=pr_ref)
r.acknowledge_entry(session, "filesystem")
def fake_resolver(_ref: str) -> str:
return "pr-blob-sha-AFTER-A-NEW-PUSH"
decision = r.sign_precondition(session, fake_resolver)
assert decision.ok is False
assert "mismatch" in decision.reason.lower() or "changed" in decision.reason.lower()
def test_sign_precondition_defaults_to_main_when_loaded_ref_unset():
"""start_review()'s loaded_ref defaults to 'main' for source=main
reviews (and backward-compat with callers that don't pass it)."""
session = r.start_review("sha1", _catalog(), _catalog(_entry()))
assert session.loaded_ref == "main"
r.acknowledge_entry(session, "filesystem")
def fake_resolver(ref: str) -> str:
assert ref == "main"
return "sha1"
decision = r.sign_precondition(session, fake_resolver)
assert decision.ok is True
# --------------------------------------------------------------------------- #
# find_last_signed_catalog_raw: what source=main diffs against
# --------------------------------------------------------------------------- #
def test_find_last_signed_catalog_raw_returns_matching_candidate():
"""Simulates walking catalog.json's git history: the CURRENT signature
covers an OLDER version of the bytes (a later commit changed
catalog.json without re-signing -- the exact bypass that produced
commit b08cf21). The first candidate that verifies against that
signature is 'the last catalog a maintainer actually signed'."""
seed, pubkey = r.generate_keypair()
old_raw = b'{"schema":1,"version":1,"servers":[]}'
new_raw = b'{"schema":1,"version":2,"servers":[]}'
sig = r.sign_catalog_bytes(old_raw, seed) # signature covers the OLD bytes
found = r.find_last_signed_catalog_raw([new_raw, old_raw], sig, [pubkey])
assert found == old_raw
def test_find_last_signed_catalog_raw_none_when_nothing_verifies():
seed, _pubkey = r.generate_keypair()
_other_seed, other_pubkey = r.generate_keypair()
raw = b'{"schema":1,"version":1,"servers":[]}'
sig = r.sign_catalog_bytes(raw, seed)
# Check against a pubkey list that does NOT include the signer's key.
assert r.find_last_signed_catalog_raw([raw], sig, [other_pubkey]) is None
# --------------------------------------------------------------------------- #
# catalog_signing_message: domain separation must match bcc_core exactly
# --------------------------------------------------------------------------- #
def test_signing_message_uses_bcc_core_domain_prefix():
raw = b'{"schema":1}'
msg = r.catalog_signing_message(raw)
assert msg == c._CATALOG_SIG_DOMAIN + raw
assert msg.startswith(b"bcc-catalog-v1|")
def test_sign_then_verify_round_trips_with_bcc_core():
"""End-to-end: a signature produced by the Console's sign_catalog_bytes
must verify with bcc_core.verify_catalog_signature -- proves the two
modules can never drift on the domain-separation prefix."""
seed, pubkey = r.generate_keypair()
raw = b'{"schema":1,"version":2,"servers":[]}'
sig = r.sign_catalog_bytes(raw, seed)
assert c.verify_catalog_signature(raw, sig, [pubkey]) is True
def test_sign_tampered_bytes_fails_verify():
seed, pubkey = r.generate_keypair()
raw = b'{"schema":1,"version":2,"servers":[]}'
sig = r.sign_catalog_bytes(raw, seed)
tampered = raw[:-1] + b"0"
assert c.verify_catalog_signature(tampered, sig, [pubkey]) is False
# --------------------------------------------------------------------------- #
# Key encryption at rest
# --------------------------------------------------------------------------- #
def test_encrypt_decrypt_round_trip():
seed, _pub = r.generate_keypair()
blob = r.encrypt_private_key(seed, "correct horse battery staple")
decrypted = r.decrypt_private_key(blob, "correct horse battery staple")
assert decrypted == seed
def test_decrypt_wrong_passphrase_raises():
seed, _pub = r.generate_keypair()
blob = r.encrypt_private_key(seed, "right passphrase")
with pytest.raises(ValueError):
r.decrypt_private_key(blob, "wrong passphrase")
def test_decrypt_corrupted_blob_raises():
seed, _pub = r.generate_keypair()
blob = r.encrypt_private_key(seed, "pass")
corrupted = blob[:-1] + bytes([blob[-1] ^ 0xFF])
with pytest.raises(ValueError):
r.decrypt_private_key(corrupted, "pass")
def test_encrypt_private_key_rejects_wrong_length_seed():
with pytest.raises(ValueError):
r.encrypt_private_key(b"too-short", "pass")
def test_encrypt_private_key_rejects_empty_passphrase():
seed, _pub = r.generate_keypair()
with pytest.raises(ValueError):
r.encrypt_private_key(seed, "")
def test_encrypted_blob_never_contains_seed_plaintext():
seed, _pub = r.generate_keypair()
blob = r.encrypt_private_key(seed, "some passphrase")
assert seed not in blob
def test_generate_keypair_produces_valid_ed25519_pair():
seed, pubkey = r.generate_keypair()
assert len(seed) == 32
assert len(pubkey) == 32
raw = b"test payload"
sig = r.sign_catalog_bytes(raw, seed)
assert c.verify_catalog_signature(raw, sig, [pubkey]) is True
# --------------------------------------------------------------------------- #
# Registry lookup / near-neighbour edit distance
# --------------------------------------------------------------------------- #
def test_edit_distance_identical():
assert r.edit_distance("abc", "abc") == 0
def test_edit_distance_one_substitution():
assert r.edit_distance("firecrawl-mcp", "f1recrawl-mcp") == 1
def test_near_neighbor_ids_finds_close_match():
others = ["firecrawl-mcp", "unrelated-server", "totally-different"]
neighbors = r.near_neighbor_ids("firecrawl-mcp2", others, max_distance=2)
assert "firecrawl-mcp" in neighbors
def test_near_neighbor_ids_excludes_self():
others = ["filesystem", "other"]
assert "filesystem" not in r.near_neighbor_ids("filesystem", others)
def test_near_neighbor_ids_excludes_far_matches():
others = ["completely-unrelated-name"]
assert r.near_neighbor_ids("filesystem", others, max_distance=2) == []
def test_extract_package_refs_npm():
entry = _entry(config={"command": "npx", "args": ["-y", "@scope/pkg@1.2.3"]})
refs = r.extract_package_refs(entry)
assert len(refs) == 1
assert refs[0].ecosystem == "npm"
assert refs[0].name == "@scope/pkg"
assert refs[0].version == "1.2.3"
def test_extract_package_refs_uvx():
entry = _entry(config={"command": "uvx", "args": ["some-pypi-pkg==1.0.0"]})
refs = r.extract_package_refs(entry)
assert len(refs) == 1
assert refs[0].ecosystem == "pypi"
assert refs[0].name == "some-pypi-pkg"
assert refs[0].version == "1.0.0"
def test_extract_package_refs_link_only_entry_returns_empty():
entry = {"id": "slack", "setup": "link-only", "docs_url": "https://example.com"}
assert r.extract_package_refs(entry) == []
def test_lookup_registry_info_fails_soft_on_none():
ref = r.PackageRef("x", "npm", "somepkg", "1.0.0")
info = r.lookup_registry_info(ref, lambda _ref: None, [])
assert info.available is False
def test_lookup_registry_info_fails_soft_on_exception():
def boom(_ref):
raise RuntimeError("network down")
ref = r.PackageRef("x", "npm", "somepkg", "1.0.0")
info = r.lookup_registry_info(ref, boom, [])
assert info.available is False
def test_lookup_registry_info_populates_fields_when_available():
ref = r.PackageRef("x", "npm", "somepkg", "1.0.0")
def fetcher(_ref):
return {
"publisher": "hello_sideguide",
"age_days": 30,
"last_release": "2026-01-01",
"downloads": 500,
}
info = r.lookup_registry_info(ref, fetcher, [])
assert info.available is True
assert info.publisher == "hello_sideguide"
assert info.downloads == 500
def test_lookup_registry_info_includes_near_neighbors():
ref = r.PackageRef("x", "npm", "firecrawl-mcp2", "1.0.0")
info = r.lookup_registry_info(ref, lambda _ref: None, ["firecrawl-mcp"])
assert "firecrawl-mcp" in info.near_neighbor_ids
# --------------------------------------------------------------------------- #
# contains_non_ascii
# --------------------------------------------------------------------------- #
def test_contains_non_ascii_true():
assert r.contains_non_ascii("pаckage") is True
def test_contains_non_ascii_false():
assert r.contains_non_ascii("package") is False
+234
View File
@@ -0,0 +1,234 @@
"""Tests for scripts/sign_checksums.py: SHA256SUMS generation and detached
Ed25519 signing/verification for release artifacts."""
from __future__ import annotations
import base64
import subprocess
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import sign_checksums as sc
cryptography = pytest.importorskip("cryptography")
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: E402
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat # noqa: E402
def _make_keypair() -> tuple[str, str]:
"""Return (seed_b64, pubkey_b64) for a fresh Ed25519 keypair."""
private_key = Ed25519PrivateKey.generate()
seed = private_key.private_bytes_raw()
pubkey = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
return base64.b64encode(seed).decode("ascii"), base64.b64encode(pubkey).decode("ascii")
# --------------------------------------------------------------------------- #
# sha256_file / build_checksums_text / generate_checksums
# --------------------------------------------------------------------------- #
def test_sha256_file_matches_hashlib(tmp_path):
f = tmp_path / "a.txt"
f.write_bytes(b"hello world")
import hashlib
assert sc.sha256_file(f) == hashlib.sha256(b"hello world").hexdigest()
def test_build_checksums_text_sorted_and_formatted():
files = {"zeta.zip": "aa" * 32, "alpha.zip": "bb" * 32}
text = sc.build_checksums_text(files)
lines = text.splitlines()
assert lines[0].endswith("alpha.zip")
assert lines[1].endswith("zeta.zip")
# Standard sha256sum format: hash, two spaces, filename.
assert lines[0] == f"{'bb' * 32} alpha.zip"
def test_build_checksums_text_empty():
assert sc.build_checksums_text({}) == ""
def test_generate_checksums_from_directory(tmp_path):
(tmp_path / "b.bin").write_bytes(b"second")
(tmp_path / "a.bin").write_bytes(b"first")
(tmp_path / "subdir").mkdir()
(tmp_path / "subdir" / "ignored.bin").write_bytes(b"nested, not hashed")
text = sc.generate_checksums(tmp_path)
lines = text.splitlines()
assert len(lines) == 2
assert lines[0].endswith("a.bin")
assert lines[1].endswith("b.bin")
assert "subdir" not in text
def test_generate_checksums_excludes_manifest_files(tmp_path):
(tmp_path / "archive.zip").write_bytes(b"payload")
(tmp_path / "SHA256SUMS").write_text("stale")
(tmp_path / "SHA256SUMS.sig").write_bytes(b"stale-sig")
text = sc.generate_checksums(tmp_path, exclude={"SHA256SUMS", "SHA256SUMS.sig"})
assert "archive.zip" in text
assert "SHA256SUMS" not in text.replace("archive.zip", "")
# --------------------------------------------------------------------------- #
# sign_checksums / verify_checksums
# --------------------------------------------------------------------------- #
def test_sign_then_verify_roundtrip():
seed_b64, pubkey_b64 = _make_keypair()
sums_text = "deadbeef" * 8 + " BetterClaudeConfig-Linux.tar.gz\n"
signature = sc.sign_checksums(seed_b64, sums_text)
assert len(signature) == 64
assert sc.verify_checksums(pubkey_b64, sums_text, signature) is True
def test_verify_rejects_tampered_checksums():
seed_b64, pubkey_b64 = _make_keypair()
sums_text = "aa" * 32 + " file.zip\n"
signature = sc.sign_checksums(seed_b64, sums_text)
tampered = "bb" * 32 + " file.zip\n"
assert sc.verify_checksums(pubkey_b64, tampered, signature) is False
def test_verify_rejects_wrong_key():
seed_b64, _ = _make_keypair()
_, other_pubkey_b64 = _make_keypair()
sums_text = "cc" * 32 + " file.zip\n"
signature = sc.sign_checksums(seed_b64, sums_text)
assert sc.verify_checksums(other_pubkey_b64, sums_text, signature) is False
def test_domain_prefix_is_applied():
"""The signed message must be prefixed, not the raw manifest bytes --
otherwise a signature over this manifest could be replayed as a
signature over an unrelated message with the same bytes elsewhere."""
seed_b64, pubkey_b64 = _make_keypair()
sums_text = "11" * 32 + " file.zip\n"
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey as PK
seed = base64.b64decode(seed_b64)
raw_signature = PK.from_private_bytes(seed).sign(sums_text.encode("utf-8"))
# A signature over the raw (unprefixed) bytes must NOT verify via our
# domain-separated verify function.
assert sc.verify_checksums(pubkey_b64, sums_text, raw_signature) is False
# But our own sign_checksums() output does verify.
good_signature = sc.sign_checksums(seed_b64, sums_text)
assert sc.verify_checksums(pubkey_b64, sums_text, good_signature) is True
def test_sign_checksums_rejects_bad_seed_length():
bad_seed_b64 = base64.b64encode(b"too-short").decode("ascii")
with pytest.raises(ValueError):
sc.sign_checksums(bad_seed_b64, "irrelevant\n")
def test_verify_checksums_rejects_bad_pubkey_length():
seed_b64, _ = _make_keypair()
sig = sc.sign_checksums(seed_b64, "irrelevant\n")
bad_pubkey_b64 = base64.b64encode(b"too-short").decode("ascii")
with pytest.raises(ValueError):
sc.verify_checksums(bad_pubkey_b64, "irrelevant\n", sig)
def test_public_key_b64_from_seed_matches_generated_pubkey():
seed_b64, pubkey_b64 = _make_keypair()
assert sc.public_key_b64_from_seed(seed_b64) == pubkey_b64
# --------------------------------------------------------------------------- #
# CLI (end-to-end, via subprocess so argparse wiring is exercised too)
# --------------------------------------------------------------------------- #
SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "sign_checksums.py"
def _run(*args, env=None):
return subprocess.run(
[sys.executable, str(SCRIPT), *args],
capture_output=True,
text=True,
env=env,
)
def test_cli_generate_sign_verify_roundtrip(tmp_path, monkeypatch):
seed_b64, pubkey_b64 = _make_keypair()
release_dir = tmp_path / "release-files"
release_dir.mkdir()
(release_dir / "BetterClaudeConfig-Linux.tar.gz").write_bytes(b"fake archive contents")
(release_dir / "BetterClaudeConfig-macOS.zip").write_bytes(b"fake zip contents")
sums_path = release_dir / "SHA256SUMS"
sig_path = release_dir / "SHA256SUMS.sig"
gen = _run("generate", str(release_dir), "--out", str(sums_path))
assert gen.returncode == 0, gen.stderr
assert sums_path.exists()
body = sums_path.read_text()
assert "BetterClaudeConfig-Linux.tar.gz" in body
assert "BetterClaudeConfig-macOS.zip" in body
sign = _run("sign", "--sums", str(sums_path), "--out", str(sig_path), "--key-b64", seed_b64)
assert sign.returncode == 0, sign.stderr
assert sig_path.exists()
assert sig_path.stat().st_size == 64
verify = _run(
"verify",
"--sums",
str(sums_path),
"--sig",
str(sig_path),
"--pubkey-b64",
pubkey_b64,
)
assert verify.returncode == 0, verify.stderr
assert "OK" in verify.stdout
def test_cli_sign_without_key_fails_loudly(tmp_path):
sums_path = tmp_path / "SHA256SUMS"
sums_path.write_text("aa" * 32 + " file.zip\n")
sig_path = tmp_path / "SHA256SUMS.sig"
import os
env = {k: v for k, v in os.environ.items() if k != "RELEASE_SIGNING_KEY"}
result = _run("sign", "--sums", str(sums_path), "--out", str(sig_path), env=env)
assert result.returncode != 0
assert not sig_path.exists(), "must never write a bogus/empty signature file"
assert "no signing key" in result.stderr.lower()
def test_cli_verify_detects_tampering(tmp_path):
seed_b64, pubkey_b64 = _make_keypair()
sums_path = tmp_path / "SHA256SUMS"
sums_path.write_text("aa" * 32 + " file.zip\n")
sig_path = tmp_path / "SHA256SUMS.sig"
_run("sign", "--sums", str(sums_path), "--out", str(sig_path), "--key-b64", seed_b64)
sums_path.write_text("bb" * 32 + " file.zip\n") # tamper after signing
verify = _run(
"verify",
"--sums",
str(sums_path),
"--sig",
str(sig_path),
"--pubkey-b64",
pubkey_b64,
)
assert verify.returncode != 0
assert "FAILED" in verify.stdout + verify.stderr
+2817
View File
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
"""Tests for the lenient JSON repair pipeline (repair_json_text +
parse_pasted_json_verbose). Every case here is a shape people actually paste
from MCP docs, blog posts, and chat windows."""
import json
import pytest
import bcc_core as c
GOOD = {"command": "npx", "args": ["-y", "@x/brave"]}
def assert_brave(servers):
assert "brave" in servers
assert servers["brave"]["command"] == "npx"
# --------------------------------------------------------------------------- #
# Valid JSON passes through untouched
# --------------------------------------------------------------------------- #
def test_strict_json_no_notes():
servers, notes = c.parse_pasted_json_verbose('{"brave": {"command": "npx", "args": []}}')
assert_brave(servers)
assert notes == []
# --------------------------------------------------------------------------- #
# Markdown / prose wrappers
# --------------------------------------------------------------------------- #
def test_markdown_fence():
text = 'Add this to your config:\n```json\n{"brave": {"command": "npx"}}\n```\nThen restart.'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("fence" in n for n in notes)
def test_fence_without_language_tag():
text = '```\n{"brave": {"command": "npx"}}\n```'
servers, _ = c.parse_pasted_json_verbose(text)
assert_brave(servers)
def test_prose_around_bare_json():
text = 'Paste the following: {"brave": {"command": "npx"}} and restart Claude.'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("before" in n or "after" in n for n in notes)
# --------------------------------------------------------------------------- #
# Comments
# --------------------------------------------------------------------------- #
def test_line_comments():
text = '{\n // the search server\n "brave": {"command": "npx"}\n}'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("//" in n for n in notes)
def test_block_comments():
text = '{ /* config */ "brave": {"command": "npx"} }'
servers, _ = c.parse_pasted_json_verbose(text)
assert_brave(servers)
def test_url_with_double_slash_is_not_a_comment():
text = '{"remote": {"url": "https://mcp.example.com/sse"}}'
servers, notes = c.parse_pasted_json_verbose(text)
assert servers["remote"]["url"] == "https://mcp.example.com/sse"
assert notes == []
# --------------------------------------------------------------------------- #
# Commas
# --------------------------------------------------------------------------- #
def test_trailing_comma_object():
text = '{"brave": {"command": "npx", "args": ["-y"],},}'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("trailing" in n for n in notes)
def test_missing_comma_between_servers():
text = '{"brave": {"command": "npx"}\n"git": {"command": "uvx"}}'
servers, notes = c.parse_pasted_json_verbose(text)
assert "brave" in servers and "git" in servers
assert any("missing comma" in n for n in notes)
def test_missing_comma_between_string_fields():
text = '{"brave": {"command": "npx"\n"args": ["-y"]}}'
servers, _ = c.parse_pasted_json_verbose(text)
assert servers["brave"]["args"] == ["-y"]
# --------------------------------------------------------------------------- #
# Quotes
# --------------------------------------------------------------------------- #
def test_smart_quotes():
text = "{“brave”: {“command”: “npx”}}"
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("smart quotes" in n for n in notes)
def test_single_quotes():
text = "{'brave': {'command': 'npx', 'args': ['-y']}}"
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("single-quoted" in n for n in notes)
def test_single_quotes_with_inner_double():
text = """{'brave': {'command': 'npx', 'env': {'NOTE': 'say "hi"'}}}"""
servers, _ = c.parse_pasted_json_verbose(text)
assert servers["brave"]["env"]["NOTE"] == 'say "hi"'
# --------------------------------------------------------------------------- #
# JS-style laxness
# --------------------------------------------------------------------------- #
def test_unquoted_keys():
text = '{brave: {command: "npx", args: ["-y"]}}'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("bare object keys" in n for n in notes)
def test_python_literals():
text = '{"brave": {"command": "npx", "enabled": True, "extra": None}}'
servers, notes = c.parse_pasted_json_verbose(text)
assert servers["brave"]["enabled"] is True
assert servers["brave"]["extra"] is None
assert any("literals" in n for n in notes)
# --------------------------------------------------------------------------- #
# Structure damage
# --------------------------------------------------------------------------- #
def test_braceless_fragment():
text = '"brave": {"command": "npx", "args": ["-y"]}'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("wrapped" in n for n in notes)
def test_missing_closing_braces():
text = '{"mcpServers": {"brave": {"command": "npx", "args": ["-y"]'
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert any("unclosed" in n for n in notes)
def test_kitchen_sink():
# fence + comment + single quotes + unquoted key + trailing comma + prose
text = (
"Here's the config you need:\n"
"```json\n"
"{\n"
" // brave search\n"
" brave: {\n"
" 'command': 'npx',\n"
" 'args': ['-y', '@x/brave',],\n"
" },\n"
"}\n"
"```\n"
)
servers, notes = c.parse_pasted_json_verbose(text)
assert_brave(servers)
assert servers["brave"]["args"] == ["-y", "@x/brave"]
assert len(notes) >= 4
# --------------------------------------------------------------------------- #
# Suspicious-args detection (several argv tokens typed on one line)
# --------------------------------------------------------------------------- #
def test_split_flag_and_value_on_one_line():
fixed, notes = c.split_suspicious_args(["--directory /path/to/server", "run", "main.py"])
assert fixed == ["--directory", "/path/to/server", "run", "main.py"]
assert len(notes) == 1
def test_split_full_option_string():
fixed, notes = c.split_suspicious_args(["-y @pkg/server --port 8080"])
assert fixed == ["-y", "@pkg/server", "--port", "8080"]
assert notes
def test_split_respects_quotes():
fixed, _ = c.split_suspicious_args(['--name "My Server"'])
assert fixed == ["--name", "My Server"]
def test_split_leaves_legit_spaces_alone():
args = ["/Users/me/My Documents", "hello world", "run"]
fixed, notes = c.split_suspicious_args(args)
assert fixed == args
assert notes == []
def test_split_leaves_clean_args_alone():
args = ["--directory", "/path/to/server", "run", "main.py"]
fixed, notes = c.split_suspicious_args(args)
assert fixed == args
assert notes == []
# --------------------------------------------------------------------------- #
# Config-file repair (load-time)
# --------------------------------------------------------------------------- #
def test_repair_config_file_fixes_and_reports(tmp_path):
p = tmp_path / "claude_desktop_config.json"
p.write_text(
'{\n "globalShortcut": "Cmd+Space",\n'
" // my servers\n"
' "mcpServers": {\n'
' "brave": {"command": "npx", "args": ["-y"],},\n'
" },\n}"
)
cfg, notes, preview = c.repair_config_file(p)
assert cfg["mcpServers"]["brave"]["command"] == "npx"
assert cfg["globalShortcut"] == "Cmd+Space" # unrelated keys survive
assert any("comment" in n for n in notes)
assert any("trailing" in n for n in notes)
assert json.loads(preview) == cfg
# nothing was written to disk
assert "//" in p.read_text()
def test_repair_config_file_hopeless_raises(tmp_path):
p = tmp_path / "claude_desktop_config.json"
p.write_text("total nonsense, no braces at all")
with pytest.raises(ValueError):
c.repair_config_file(p)
# --------------------------------------------------------------------------- #
# Still fails cleanly on hopeless input
# --------------------------------------------------------------------------- #
def test_hopeless_input_raises_value_error():
with pytest.raises(ValueError):
c.parse_pasted_json_verbose("this is just prose with no json at all")
def test_junk_object_still_rejected():
with pytest.raises(ValueError):
c.parse_pasted_json_verbose('{"junk": 5}')
def test_empty_raises():
with pytest.raises(ValueError):
c.parse_pasted_json_verbose(" ")