fix(agent): import cryptography lazily in the Bitwarden secret source (#86735) - #86826
pittosporum-seu wants to merge 1 commit into
Conversation
…NousResearch#86735) Every CLI/gateway process imports the secret-source registry, which unconditionally registers the bundled Bitwarden source — whose module imported cryptography.hazmat at module level. On Windows a mapped native extension (cryptography._rust) cannot be replaced once loaded, so 'hermes update' tripped its self-lock preflight before it could even pull commits, failing with exit 2 on every update. Move the cryptography imports into the three functions that actually use them (HKDF key derivation, AESGCM encrypt/decrypt), so a process that never reads an encrypted BWS cache never loads cryptography. Registry import stays lazy and cheap as designed. Regression test: in a clean interpreter, registering the builtin sources must not leave 'cryptography' or 'cryptography.hazmat.bindings._rust' in sys.modules.
commented
Aug 15, 2026
left a comment
There was a problem hiding this comment.
Review from SMF Works
Verdict: Comment (support merge) — no blocking issues.
What this does
bitwarden.py imported cryptography.hazmat at module level. The secret-source registry loads that module for every CLI/gateway process, so Windows hermes update always hits _detect_self_loaded_native_modules (cryptography (_rust.pyd)) and exits 2 before it can pull.
The PR moves HKDF/AESGCM imports into the three functions that actually encrypt/decrypt the BWS cache. A cold update that never reads an encrypted cache no longer maps _rust.pyd.
Verified on the files
- Module-level
from cryptography...is gone; only a NOTE remains. - Lazy imports sit in
_derive_encrypted_cache_key,_write_encrypted_disk_cache,_read_encrypted_disk_cache— those are the only call sites. TestNoEagerCryptographyImportruns_ensure_builtin_sources()in a subprocess so the test runner's own imports cannot mask a regression. That's the right shape.
Residual (non-blocking)
- Current
mainalso landedfix(secrets_cli): defer bitwarden backend import to first attribute access(3f9150e5c). Complementary layer, not a substitute: any other import ofbitwarden.pywould still have been eager. Keep both. - If a process does touch the encrypted BWS cache,
cryptographystill loads and a later in-processhermes updatewill still self-lock. That's the existing Windows contract, not a regression. - Test does not re-exercise encrypt/decrypt after the move; existing
tests/test_bitwarden_secrets.pyshould cover that.
Ready for review/merge from our side.
left a comment
There was a problem hiding this comment.
SMF Works review — #86826
Verdict: Reviewed (COMMENT) — no blocking correctness bug in the Bitwarden lazy-import itself. Not an approve: overlapping earlier PRs + one residual Windows-update hole remain.
PR: #86826 — fix(agent): import cryptography lazily in the Bitwarden secret source (#86735)
Author: @pittosporum-seu
Files: 2 (+57 / −3) — agent/secret_sources/bitwarden.py, tests/secret_sources/test_secret_source_registry.py
Base: main@f0c222c7 (opened before #86782 merged)
What this PR does
Moves the three cryptography.hazmat imports off bitwarden.py module import and into the only call sites that need them:
_derive_encrypted_cache_key→hashes+HKDF_write_encrypted_disk_cache→AESGCM.encrypt_read_encrypted_disk_cache→AESGCM.decrypt
That matches the #86735 chain: load_hermes_dotenv → secret registry → from agent.secret_sources.bitwarden import BitwardenSource used to map cryptography._rust.pyd before update_cmd._detect_self_loaded_native_modules ran, so Windows hermes update deferred at exit 2 before git fetch.
What I verified on the files
| Checklist item | Result |
|---|---|
| Stops cryptography at module load? | Yes. HEAD bitwarden.py has no module-level from cryptography / import cryptography. Registry _ensure_builtin_sources() can import BitwardenSource without touching hazmat. |
| AESGCM / HKDF / hashes still available when encryption is used? | Yes. Same constructors, same arguments, same AAD (serialized_key). Write/read still sit in their existing best-effort except Exception envelopes, so an ImportError becomes a cache miss / skipped write — same fail-open as a missing wheel would have been at module import (except now Bitwarden still registers). |
| Leftover eager import in this module? | None. Only those three functions referenced HKDF / AESGCM / hashes on main; all three now import locally. |
| Tests? | Partial, but real. TestNoEagerCryptographyImport runs _ensure_builtin_sources() in a clean subprocess and asserts cryptography and cryptography.hazmat.bindings._rust stay out of sys.modules. Subprocess is the right isolation (parent file's autouse fixture stubs _ensure_builtin_sources). Pre-existing tests/test_bitwarden_secrets.py (test_encrypted_cache_writes_without_plaintext, test_encrypted_cache_falls_back_on_network_error) still exercise AES-GCM write/read. |
| Windows self-lock list still correct? | Yes — and this PR correctly does not touch it. _SELF_LOCKING_NATIVE_MODULES on current main is still only cryptography.hazmat.bindings._rust → cryptography (_rust.pyd). Detector is prefix-in-sys.modules, Windows-only. Keep it as defence-in-depth. |
CI (head a0fc72db) |
All 12 Python test slices + e2e + Windows-only + ruff + Windows footguns green. Check contributors / check-attribution failed (process/CLA, not a logic defect). |
Context the PR description is slightly behind
#86782 merged onto main after this PR opened (3f9150e5, 08:55Z). It already:
- lazy-loads
secrets_cli/ the Bitwarden backend - gates
env_loaderregistry import onany_enabled
So a cold hermes update with no enabled secret source is already crypto-free on current main. This PR is still the missing half: _ensure_builtin_sources() unconditionally imports bitwarden.py the moment any source is enabled (1Password, command, plugin, or Bitwarden). Without this lazy import, a 1Password-only Windows user still maps _rust.pyd and still cannot update.
The module NOTE ("imported unconditionally … for every CLI/gateway process") is therefore a bit strong against post-#86782 main, but the code change is still the right one.
💡 Suggestions (non-blocking)
-
Earlier open twin: #84114 (2026-08-11, @nanami7777777) does the same three-site lazy import. Its tests are stronger on a different axis:
MetaPathFinderblockscryptographyentirely and asserts Bitwarden + command still register. This PR's subprocesssys.modulescheck is stronger on the actual Windows self-lock predicate. Land one; close the other with credit. If this one lands, consider grafting #84114's "broken optional wheel must not poison registration" test — that is the other half of the registry's "broken-source-safe" contract. -
Broader sibling: #77517 also lazy-imports Bitwarden crypto and skips external secret fetch on the update subcommand. That is the only approach that keeps
_rust.pydunmapped whensecrets.bitwarden.encrypted_cache.enabledis on (see residual risk). -
Test does not import
hermes_cli.main/ runcmd_update. Fine for this diff (the registry is the remaining eager edge after #86782). A one-liner comment pointing at that would save the next reviewer a trip.
Residual risk (not a request-changes)
- Encrypted-cache users still self-lock on update.
encrypted_cache_enableddefaults false. If it is true,fetch_bitwarden_secrets(dotenv apply, before the preflight atupdate_cmd.py~4324) will call_read_encrypted_disk_cache/_write_encrypted_disk_cacheand load_rust.pydin the updater process. This PR does not claim to fix that path; #77517's "don't apply external secrets during update" would. - Other module-level
cryptographyimports exist off the update path (plugins/platforms/wecom/wecom_crypto.py; Weixin adapter). Not this PR's job; the self-lock list will still catch them if they ever get imported at CLI startup. - Plugin discovery's
_refresh_secret_sources_after_discoverystill imports the registry; after this PR that import is cheap unless someone then uses the encrypted cache.
Looks good
- Minimal, on-target diff — no drive-by update-cmd / env_loader rewrite.
- Comments name the Windows mapped-image failure and #86735.
- Subprocess regression cannot be masked by the test runner already having imported cryptography.
SMF Works review — files + GitHub API + current main self-lock / env_loader / #86782 overlay. No competing PR opened.
commented
Aug 15, 2026
|
Thanks @pittosporum-seu — your diagnosis and fix were correct, and the exact change you made here (lazy cryptography imports inside Closing as superseded rather than merging a now-conflicting duplicate — but this was a solid, correctly-scoped fix and we'd be glad to see you pick up other issues. Sorry the overlap cost you the merge on this one; two people racing the same urgent regression is a good problem for the project to have. |
What does this PR do?
hermes updatefails with exit 2 on Windows on every run:Root cause (as located in the issue): every CLI/gateway process imports the secret-source registry, which unconditionally registers the bundled Bitwarden source — and
agent/secret_sources/bitwarden.pyimportedcryptography.hazmatat module level. On Windows a mapped native extension (cryptography._rust) cannot be replaced once loaded, so the updater's self-lock preflight (_detect_self_loaded_native_modulesinupdate_cmd.py) deferred every update before it could even pull commits.The
cryptographyimports are now lazy — inside the three functions that actually use them (HKDF key derivation, AESGCM encrypt/decrypt). A process that never reads an encrypted BWS cache never loadscryptography, so the self-lock preflight stays quiet on cold updates. The registry's "lazy, cheap, broken-source-safe" design (documented in_ensure_builtin_sources) is preserved.Related Issue
Fixes #86735
Type of Change
Changes Made
agent/secret_sources/bitwarden.py: removed module-levelfrom cryptography...imports (with a NOTE explaining why); lazy imports in_derive_encrypted_cache_key,_write_encrypted_disk_cache,_read_encrypted_disk_cachetests/secret_sources/test_secret_source_registry.py:TestNoEagerCryptographyImport— in a clean subprocess interpreter,_ensure_builtin_sources()must not leavecryptographyorcryptography.hazmat.bindings._rustinsys.modules(subprocess so the test runner's own imports cannot mask a regression)How to Test
pytest tests/secret_sources/test_secret_source_registry.py tests/test_bitwarden_secrets.py -q— new regression test passes; encrypted-cache behavior unchanged (pre-existing Windows env failures unrelated: NTFS 0o600, bws.exe naming)ruff check+check-windows-footguns.py --diff— cleanhermes updateno longer trips the self-lock preflight on a cold process; Bitwarden encrypted cache still works when configuredChecklist
Code
fix(scope):)pytestand all tests passDocumentation & Housekeeping