Skip to content

fix(security): write BWS disk cache encrypted-only, migrate legacy plaintext - #77008

Open
andrexibiza wants to merge 5 commits into
NousResearch:mainfrom
andrexibiza:fix/security-bws-cache-encrypted-default
Open

fix(security): write BWS disk cache encrypted-only, migrate legacy plaintext#77008
andrexibiza wants to merge 5 commits into
NousResearch:mainfrom
andrexibiza:fix/security-bws-cache-encrypted-default

Conversation

@andrexibiza

@andrexibiza andrexibiza commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Related #60295 #69054

What changed and why

The Bitwarden Secrets Manager disk cache used to persist every fetched secret value in plaintext at <hermes_home>/cache/bws_cache.json. The encrypted AES-GCM cache (bws_cache.enc.json) already existed but was opt-in and off by default — so every user of the built-in BWS integration had their full set of provider keys sitting readable on disk.

This PR makes encryption the default and the only storage policy:

  • encrypted_cache.enabled now defaults to true (config defaults + source schema + function signatures).
  • The plaintext write/read/fallback branches in fetch_bitwarden_secrets are removed entirely. There is no config value that writes plaintext again. With encryption explicitly disabled, the disk cache is skipped (memory-only) rather than falling back to plaintext.
  • A legacy plaintext bws_cache.json from an older Hermes is re-encrypted and removed on first read (_migrate_legacy_plaintext_cache), so existing users are migrated automatically — no plaintext cache survives the upgrade.

Why this matters to you as a user

Today, every API key you keep in Bitwarden Secrets Manager is copied to a plain-text file on your computer so Hermes doesn't have to re-fetch it on every launch. Anyone with access to your machine, a backup, or a cloud-synced folder can open that file and see all your keys. After this change, that file is encrypted — your keys are unreadable to anyone without the right access, even if the file itself leaks. No action needed from you; it happens automatically when you upgrade.

Reproduction steps (current behavior on main)

  1. Configure BWS in config.yaml:
    secrets:
      bitwarden:
        enabled: true
        project_id: "<your-bws-project-id>"
        access_token_env: "BWS_ACCESS_TOKEN"
  2. Export BWS_ACCESS_TOKEN, start Hermes once.
  3. Observe <hermes_home>/cache/bws_cache.json — contains your secret values in plaintext (e.g. "value": "sk-...").

Current: plaintext bws_cache.json written by default.
Expected: only bws_cache.enc.json (AES-GCM ciphertext) exists; plaintext is never written, and any pre-existing plaintext file is re-encrypted and removed on first read.

How to test

  • scripts/run_tests.sh tests/test_bitwarden_secrets.py → 19 passed (3 new regression tests: encrypted-only default, legacy migration, offline-start migration).
  • Manual: after upgrading with a pre-existing bws_cache.json, start Hermes once and confirm the file is gone and bws_cache.enc.json exists with no plaintext substrings.

Platforms tested

  • Windows 11 (git-bash), Python 3.11 — full file ran; two pre-existing Windows-only failures unrelated to this change (test_install_bws_happy_path fixture zip member naming, chmod 0o600 not enforced on Windows — both fail on pristine main too).
  • CI-parity runner scripts/run_tests.sh used; git diff --check clean; check-windows-footguns.py clean on changed files.

Related

Part of #77165

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint area/auth Authentication, OAuth, credential pools area/config Config system, migrations, profiles sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 2, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing a verified at-rest secret exposure: current main defaults to the plaintext Bitwarden cache at agent/secret_sources/bitwarden.py:504, reads it at :541-549, and writes it at :618-629.

Problems

  • agent/secret_sources/bitwarden.py:523-529 returns the legacy entry regardless of whether _write_encrypted_disk_cache succeeded. That writer deliberately swallows write failures at :445-446, so a failed migration can still serve the entry while bws_cache.json remains plaintext.
  • agent/secret_sources/bitwarden.py:654-659 uses float("inf") for legacy offline fallback, bypassing encrypted_cache_max_stale_seconds even though the encrypted fallback at :638-643 honors it.
  • Explicit prior encrypted_cache.enabled: false configurations skip migration entirely (:588-601, :636). The startup loader preserves raw user config at hermes_cli/env_loader.py:614-615, so such existing plaintext files remain indefinitely.

Suggested changes

  • Return/verify encryption-and-removal success before serving a migrated entry, with a forced-write-failure regression test.
  • Enforce the configured stale bound for legacy fallback, and define removal behavior for explicit memory-only mode.
  • Update website/docs/user-guide/secrets/bitwarden.md:108-123 and cli-config.yaml.example:1642-1644, which still document encryption as opt-in.

This is an automated hermes-sweeper review.

Comment thread agent/secret_sources/bitwarden.py Outdated
entry=legacy,
home_path=home_path,
)
return legacy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_write_encrypted_disk_cache deliberately swallows ordinary failures and returns no status, so this can return the legacy value even when encryption or plaintext removal failed. Make migration return a cache hit only after a verified successful encrypted write/removal, and cover the write-failure path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in b466156c45 — migration now verifies the encrypted write before the legacy plaintext cache is removed: _write_encrypted_disk_cache returns bool and the migration path checks it (agent/secret_sources/bitwarden.py:528). Verified at head e46d020bcd: 5/5 migration/stale tests pass.

Comment thread agent/secret_sources/bitwarden.py Outdated
stale = _migrate_legacy_plaintext_cache(
cache_key=cache_key,
access_token=access_token,
max_age_seconds=float("inf"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bypasses encrypted_cache_max_stale_seconds: a network failure can serve arbitrarily old legacy plaintext data even when the configured stale limit is zero. Apply the same bound used for the encrypted fallback, or migrate without serving data outside that bound.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in b466156c45 — the legacy-plaintext fallback is bounded by encrypted_cache_max_stale_seconds instead of float('inf'), so an offline stale cache no longer serves plaintext indefinitely. Verified at head e46d020bcd: 5/5 migration/stale tests pass.

f"bws live fetch failed ({exc}); "
f"falling back to stale disk cache ({int(age)}s old)"
]
if not encrypted_cache_enabled:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An existing configuration with encrypted_cache.enabled: false now takes this path and never migrates or removes its pre-existing bws_cache.json. If false means memory-only, remove the legacy plaintext cache without reading/serving it so the advertised upgrade cleanup still holds.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed — _remove_legacy_plaintext_cache(home_path) is now called before the raise in both the fresh-cache path and this offline fallback path. Legacy plaintext is always deleted on upgrade even when encrypted_cache.enabled: false.

@andrexibiza

Copy link
Copy Markdown
Contributor Author

Addressed all three sweeper items:

  1. Migration verification_write_encrypted_disk_cache now returns bool; _migrate_legacy_plaintext_cache only returns the legacy value after a verified encrypted write/removal. Write-failure regression test added.
  2. Stale bound enforcement — offline fallback path uses encrypted_cache_max_stale_seconds instead of float("inf"). Regression test verifies max_stale_seconds=0 raises and a generous bound serves+migrates.
  3. Legacy cleanup on disable_remove_legacy_plaintext_cache deletes bws_cache.json when encrypted_cache_enabled=false (both fresh and offline paths). Regression test added.

@andrexibiza
andrexibiza force-pushed the fix/security-bws-cache-encrypted-default branch from 8968fd7 to 9a67533 Compare August 2, 2026 19:16
@andrexibiza

Copy link
Copy Markdown
Contributor Author

Fixed the stale-cache test — was failing because the offline fallback now uses encrypted_cache_max_stale_seconds instead of float('inf'). Test now passes an explicit bound so the 1h-old cache entry is still in window.

andrexibiza added a commit to andrexibiza/hermes-agent that referenced this pull request Aug 3, 2026
…cription, skill test

Addresses teknium1's review on NousResearch#77097:

1. 'Not true on main' — the security contract is now explicitly scoped
   as implemented by the secrets-exfiltration hardening series
   (NousResearch#77008/NousResearch#77012/NousResearch#77020/NousResearch#77027/NousResearch#77031/NousResearch#77039). The docs state current
   main behavior plainly (plaintext bws_cache.json read/written when
   encryption disabled, default false) and keep the rotation instruction
   mandatory today, since that exposure already exists on main. The
   posture framing stays — this eliminates an entire vulnerability
   class — but the claim is now sequenced truthfully.
2. Skill description shortened to 53 chars, one sentence, ends with a
   period (AGENTS.md hardline).
3. tests/skills/test_bitwarden_secrets_skill.py added: validates
   frontmatter, description length, required sections, user-only
   rotation + clipboard discipline, honest series scoping (no claim the
   gate test is on main), and docs-page metadata consistency.
4. Clipboard discipline added to rotation instructions (docs + skill):
   create token, copy to clipboard, paste into terminal, save nowhere
   in between.
@andrexibiza

Copy link
Copy Markdown
Contributor Author

Review receipts — both comments from 2026-08-02 on agent/secret_sources/bitwarden.py:

1) Migration cache-hit integrity (comment @ _migrate_legacy_plaintext_cache):
Addressed in b466156c45 — migration now checks the _write_encrypted_disk_cache return value and returns None when the encrypted write/removal fails, so the caller falls through to a live fetch instead of serving a value whose plaintext file may still be on disk. Regression: test_migration_skips_legacy_when_encrypted_write_fails.

2) Legacy-plaintext stale bound (comment @ fetch_bitwarden_secrets fallback):
Addressed in b466156c45 — the offline fallback now enforces encrypted_cache_max_stale_seconds (was float('inf')) for legacy plaintext migration; a network failure can no longer serve arbitrarily old data outside the configured bound. Regression: test_legacy_plaintext_offline_fallback_respects_stale_bound. Also removes legacy plaintext on encrypted_cache_enabled=False (test_legacy_plaintext_removed_when_encryption_disabled).

5/5 targeted tests pass.

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The encrypted-only default removes ordinary plaintext writes and bounds stale fallback, but legacy plaintext cleanup is still not guaranteed on two reachable paths. A pre-upgrade bws_cache.json can remain after migration reports success when deletion fails, and it can remain after a successful live fetch when disk encryption is disabled with a zero cache TTL or when a caller bypasses caching.

  • [P2] Do not report migration success when legacy plaintext removal fails (agent/secret_sources/bitwarden.py:443)
    _write_encrypted_disk_cache swallows OSError from unlinking bws_cache.json and still returns True, so _migrate_legacy_plaintext_cache serves the legacy value while the plaintext file remains. Separately, _remove_legacy_plaintext_cache is only reached inside the positive-TTL cache branch or the transport-error branch; with cache_ttl_seconds=0 or use_cache=False and encrypted_cache_enabled=False, a successful live fetch leaves a pre-upgrade plaintext file untouched.
    Remediation: treat legacy unlink failure as migration failure (or verify the file is absent before returning success), and run legacy-file cleanup independently of cache TTL/use-cache settings whenever the encrypted-only policy is active.

Security evidence:

  • trust boundary: The BWS subprocess and legacy on-disk cache contain secret values; the profile cache directory is the persistence boundary.
  • source/sink/invariant: Secret values may reach only process memory or AES-GCM ciphertext on disk; after migration or when disk persistence is disabled, bws_cache.json must not remain.
  • current-main reproduction: Current main still defaults to the plaintext DiskCache; a successful fetch writes the secret value to bws_cache.json and no encrypted file.
  • PR-head or patch-replay validation: The exact PR head replayed cleanly onto current main; the unlink-error, zero-TTL disabled-encryption, and use-cache bypass probes all leave the plaintext file in place.
  • positive/negative cases: The focused PR-head suite passes; its migration, encrypted-write, stale-bound, and disabled-encryption tests do not cover unlink failure or zero-TTL/use-cache bypasses.
  • residual bypass search: All Bitwarden cache reads, writes, migration, removal, and call sites were traced; the cleanup branches above remain reachable.
  • reviewer validation: Source flow and deterministic temp-home probes confirm both residual plaintext exposures.

Not checked:

  • CodeRabbit review

Signed: GPT-5.6-sol-xhigh in Codex

@andrexibiza

Copy link
Copy Markdown
Contributor Author

Fixed in bcb9b51d8f. Both P2 items addressed:

1) Migration success no longer reported when legacy removal fails
_write_encrypted_disk_cache previously swallowed OSError from unlinking bws_cache.json and still returned True, so _migrate_legacy_plaintext_cache served the legacy value while the plaintext file remained. It now returns False on unlink failure, and migration refuses to serve the value — the caller falls through to a live fetch rather than trusting an incomplete migration.

2) Legacy cleanup independent of cache TTL / use-cache settings
_remove_legacy_plaintext_cache was only reachable inside the positive-TTL branch or the transport-error branch. With cache_ttl_seconds=0 or use_cache=False and encrypted_cache_enabled=False, a successful live fetch left a pre-upgrade plaintext file untouched. The success path now removes the legacy file unconditionally after a live fetch, and the error path removes it before re-raising whenever the encrypted-only policy is active.

Regression tests added (tests/test_bitwarden_secrets.py):

  • test_legacy_unlink_failure_blocks_migration — unlink-error probe: encrypted write returns False, migration returns None, plaintext never served
  • test_legacy_plaintext_removed_after_successful_fetch_without_disk_cache (parametrized over cache_ttl_seconds=0 and use_cache=False) — zero-TTL / use-cache bypass probes
  • test_legacy_plaintext_removed_when_fetch_fails_and_cache_bypassed — failed fetch with use_cache=False still removes the legacy file

Validation: scripts/run_tests.sh tests/test_bitwarden_secrets.py → 26 passed, 2 failed (both pre-existing Windows-only failures on the unmodified PR head: test_install_bws_happy_path, test_encrypted_cache_writes_without_plaintext 0600-mode assertion — confirmed identical before this change). Ruff and git diff --check pass.

@andrexibiza

Copy link
Copy Markdown
Contributor Author

Final consolidated receipts — every review item from teknium1 (hermes-sweeper) and egilewski is addressed at head bcb9b51d8f, verified against the code at that commit, with CI green.

teknium1 (hermes-sweeper)

  1. Migration integrity — _write_encrypted_disk_cache now returns bool; _migrate_legacy_plaintext_cache returns the legacy entry only after a verified encrypted write/removal (agent/secret_sources/bitwarden.py:531-538). Regression: test_migration_skips_legacy_when_encrypted_write_fails.
  2. Stale bound — the legacy offline fallback honors encrypted_cache_max_stale_seconds instead of float("inf") (bitwarden.py:686-691). Regression: test_legacy_plaintext_offline_fallback_respects_stale_bound.
  3. Memory-only cleanup — _remove_legacy_plaintext_cache runs on both the fresh-cache path (bitwarden.py:631) and the transport-error path (703) when encrypted_cache_enabled=false. Regression: test_legacy_plaintext_removed_when_encryption_disabled.
  4. Docs — website/docs/user-guide/secrets/bitwarden.md:122 and cli-config.yaml.example:1642-1643 now document encrypted_cache.enabled: true as the default.

egilewski

  1. [P2] Migration success no longer reported when legacy removal fails — _write_encrypted_disk_cache returns False on legacy-unlink OSError (bitwarden.py:446-447), so migration serves nothing and the caller falls through to a live fetch. Regression: test_legacy_unlink_failure_blocks_migration.
  2. [P2] Legacy cleanup independent of TTL/use-cache settings — the success path removes the legacy plaintext file unconditionally after a live fetch (bitwarden.py:723, outside the cache guard), the error path removes it before re-raising (703), and the memory-only fresh path removes it (631). Regressions: test_legacy_plaintext_removed_after_successful_fetch_without_disk_cache (parametrized over cache_ttl_seconds=0 and use_cache=False) and test_legacy_plaintext_removed_when_fetch_fails_and_cache_bypassed.

Verification

  • tests/test_bitwarden_secrets.py at head: 26 passed, 2 failed. Both failures (test_install_bws_happy_path, test_encrypted_cache_writes_without_plaintext 0600-mode assertion) reproduce identically at the pre-change head e46d020bc — pre-existing Windows-only artifacts, not introduced by this change.
  • All 7 review regression tests pass at head.
  • CI at bcb9b51d8f: 38/38 check runs green; PR mergeable, clean.

Ready for re-review.

@andrexibiza

Copy link
Copy Markdown
Contributor Author

@egilewski — accepted as a valid residual. The encrypted-only default does not close the class while legacy bws_cache.json can survive an unlink failure, a zero-TTL successful fetch, or a use_cache=False path.

I have recorded the two required changes separately: migration success must not be reported while the legacy file remains, and cleanup must run independently of the cache/fetch branch when the encrypted-only policy is active. The focused tests need the unlink-failure and zero-TTL/bypass cases before this PR can be described as class-complete.

Source comment: #77008 (comment)

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The new helper return value still does not close persistent legacy-plaintext cleanup failures at the production boundary. _write_encrypted_disk_cache() now returns False when bws_cache.json cannot be unlinked, but the successful-live-fetch path ignores that result and _remove_legacy_plaintext_cache() also swallows the same OSError. In an invocation-level probe, the replay returned fresh secrets with no warning while both the legacy plaintext file and the encrypted cache remained on disk. Please propagate this cleanup failure (or otherwise fail closed with a caller-visible incomplete-migration result) before reporting a normal successful fetch, and cover the top-level fetch path rather than only the helper.

Security evidence:

  • trust boundary: a pre-upgrade plaintext credential cache crosses the upgrade/migration boundary into the new encrypted-only storage policy.
  • source/sink/invariant: fetch_bitwarden_secrets() must not report normal encrypted-only success while bws_cache.json still contains secret values; the writer's False result and cleanup OSError are currently discarded.
  • current-main reproduction: a seeded legacy cache plus a persistent unlink denial returned the live value with no warning and left plaintext and encrypted cache files together.
  • PR-head or patch-replay validation: the clean current-main replay produced the same outcome even though the lower-level writer now reports False.
  • positive/negative cases: with ordinary unlink behavior the legacy file was removed; with a persistent denial it survived and the fetch still returned normally.
  • residual bypass search: the migration read, successful live-fetch write, memory-only cleanup, zero-TTL, and cache-bypass branches were checked; the ignored production-path cleanup result is the remaining same-sink gap.
  • reviewer validation: the exact head merged cleanly with current main, the isolated invocation probes resolved the expected module in each tree, and the patch passed whitespace validation.

Not checked:

  • Pytest validation
  • CodeRabbit review

Signed: GPT-5.6-sol-xhigh in Codex

@andrexibiza
andrexibiza force-pushed the fix/security-bws-cache-encrypted-default branch from bcb9b51 to 98cb9c4 Compare August 16, 2026 00:16
…aintext

The Bitwarden Secrets Manager disk cache used to persist every fetched
secret value in plaintext at <hermes_home>/cache/bws_cache.json. The
encrypted AES-GCM cache (bws_cache.enc.json) existed but was opt-in and
off by default.

This change makes encryption the default and the only storage policy:
- encrypted_cache.enabled now defaults to true (config defaults + schema)
- the plaintext write/read/fallback branches are removed entirely; with
  encryption disabled the disk cache is skipped (memory only) rather than
  falling back to plaintext
- a legacy plaintext bws_cache.json from an older Hermes is re-encrypted
  and removed on first read, so no user upgrade leaves secrets at rest

Why this matters to users: today every API key you keep in Bitwarden
Secrets Manager is copied to a plain-text file on your computer so Hermes
doesn't re-fetch on every launch. Anyone with access to your machine, a
backup, or a cloud-synced folder can read that file and see all your
keys. After this change the file is encrypted — your keys are unreadable
to anyone without the right access, even if the file itself leaks. No
action needed; it happens automatically on upgrade.

Tests: 19 passed (3 new regression tests cover encrypted-only default,
legacy migration, and offline-start migration). Two pre-existing Windows
failures (fixture zip member naming, chmod 0600 not enforced) are
platform artifacts unrelated to this change.
…le bound, remove legacy on disable

sweeper flags addressed:
1. _migrate_legacy_plaintext_cache now checks _write_encrypted_disk_cache
   return value — returns None when the encrypted write/removal fails,
   so the caller falls through to a live fetch rather than serving a
   value whose plaintext file may still be on disk.
2. Offline fallback path uses encrypted_cache_max_stale_seconds instead
   of float('inf') for legacy plaintext migration.
3. Legacy plaintext cache is removed when encrypted_cache_enabled=False.

Regression tests:
- test_migration_skips_legacy_when_encrypted_write_fails
- test_legacy_plaintext_offline_fallback_respects_stale_bound
- test_legacy_plaintext_removed_when_encryption_disabled
…nds bound

The old test relied on float('inf') in the offline fallback path.
Now that migration uses the configured max_stale_seconds, pass an explicit
bound so the test's stale cache entry (1h old) is still within window.
…e config

Sweeper flagged that website/docs/user-guide/secrets/bitwarden.md
and cli-config.yaml.example still documented encryption as opt-in.
Updated both to reflect the new default (encrypted-only disk cache,
enabled: true).
… up legacy cache independent of TTL/use-cache

Addresses review on PR NousResearch#77008:

1. _write_encrypted_disk_cache no longer swallows OSError from unlinking
   the legacy plaintext bws_cache.json and reporting success. A legacy
   file that cannot be removed means migration is incomplete, so the
   function returns False and _migrate_legacy_plaintext_cache refuses to
   serve the value while the plaintext copy is still at rest.

2. Legacy plaintext cleanup now runs independently of cache TTL and
   use-cache settings. Previously _remove_legacy_plaintext_cache was
   only reached inside the positive-TTL cache branch or the
   transport-error branch; with cache_ttl_seconds=0 or use_cache=False
   and encryption disabled, a successful live fetch left a pre-upgrade
   plaintext file untouched. The success path and the error path now
   both remove the legacy file whenever the encrypted-only policy is
   active.
@andrexibiza
andrexibiza force-pushed the fix/security-bws-cache-encrypted-default branch from 98cb9c4 to 39fb8c9 Compare August 16, 2026 00:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools area/config Config system, migrations, profiles comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants