Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions agent/file_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ def build_write_denied_paths(home: str) -> set[str]:
# Top-level Anthropic PKCE credential store remains sensitive even
# when a profile is active; default/non-profile sessions still read it.
str(hermes_root / ".anthropic_oauth.json"),
# Bitwarden Secrets Manager encrypted disk cache.
str(hermes_home / "cache" / "bws_cache.enc.json"),
str(hermes_root / "cache" / "bws_cache.enc.json"),
os.path.join(home, ".netrc"),
os.path.join(home, ".pgpass"),
os.path.join(home, ".npmrc"),
Expand Down
275 changes: 242 additions & 33 deletions agent/secret_sources/bitwarden.py

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -1489,7 +1489,10 @@ updates:
# access_token_env: BWS_ACCESS_TOKEN # bootstrap token, sourced from .env
# project_id: "" # UUID of the BSM project to sync
# server_url: "" # "" = US Cloud; EU/self-hosted URL otherwise
# cache_ttl_seconds: 300 # 0 disables caching
# cache_ttl_seconds: 300 # 0 disables fresh caching
# encrypted_cache: # optional encrypted stale fallback
# enabled: false
# max_stale_seconds: 0 # 0 disables stale fallback
# override_existing: true # BSM values win over existing env
# auto_install: true # lazy-download bws into ~/.hermes/bin
#
Expand Down
3 changes: 2 additions & 1 deletion gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1187,8 +1187,9 @@ def _media_delivery_denied_paths() -> List[Path]:
os.path.join("auth", "google_oauth.json"),
# Webhook subscription HMAC secrets.
"webhook_subscriptions.json",
# Bitwarden Secrets Manager plaintext disk cache.
# Bitwarden Secrets Manager plaintext and encrypted disk caches.
os.path.join("cache", "bws_cache.json"),
os.path.join("cache", "bws_cache.enc.json"),
)
# Directory trees whose every child is credential material.
#
Expand Down
12 changes: 11 additions & 1 deletion hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3374,8 +3374,18 @@ def _ensure_hermes_home_managed(home: Path):
"access_token_env": "BWS_ACCESS_TOKEN",
# UUID of the BSM project to sync from.
"project_id": "",
# Seconds to cache fetched secrets in-process. 0 disables.
# Seconds to reuse a fresh disk/memory cache entry before contacting
# Bitwarden again. 0 disables normal fresh-cache reuse.
"cache_ttl_seconds": 300,
# Optional encrypted last-good fallback for network/timeout outages.
# When enabled, successful BWS fetches write AES-GCM encrypted cache
# material under ~/.hermes/cache/. If a later startup cannot reach
# Bitwarden due to NETWORK/TIMEOUT, Hermes may use this encrypted
# cache for up to max_stale_seconds. Auth failures do not fall back.
"encrypted_cache": {
"enabled": False,
"max_stale_seconds": 0,
},
# When True, BSM values overwrite existing env vars. Default
# True because the point of using BSM is centralized rotation —
# if .env had the final say, rotating in Bitwarden wouldn't
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1693,6 +1693,7 @@ class ManagedFilesPolicy:
"google_oauth.json",
"webhook_subscriptions.json",
"bws_cache.json",
"bws_cache.enc.json",
# git's credential-store helper cache (agent.file_safety blocks this too).
".git-credentials",
})
Expand Down
16 changes: 16 additions & 0 deletions tests/gateway/test_platform_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@
)


def test_media_delivery_denies_encrypted_bitwarden_cache(tmp_path, monkeypatch):
"""Encrypted Bitwarden cache is covered by the media credential guard."""
import gateway.platforms.base as base

hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setattr(base, "_HERMES_HOME", hermes_home)
monkeypatch.setattr(base, "_HERMES_ROOT", hermes_home)
path = hermes_home / "cache" / "bws_cache.enc.json"
path.parent.mkdir()
path.write_text("encrypted-secret-cache")

assert path in base._media_delivery_denied_paths()
assert base.validate_media_delivery_path(str(path)) is None


class TestInboundMediaSizeCap:
"""gateway.max_inbound_media_bytes caps inbound media buffered into RAM (#13145)."""

Expand Down
1 change: 1 addition & 0 deletions tests/hermes_cli/test_web_server_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ def test_other_credential_store_basenames_blocked(forced_files_client):
"google_oauth.json",
"webhook_subscriptions.json",
"bws_cache.json",
"bws_cache.enc.json",
):
p = root / name
p.write_text("SECRET=abc123")
Expand Down
236 changes: 236 additions & 0 deletions tests/test_bitwarden_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -870,17 +870,253 @@ def test_disk_cache_corrupt_file_falls_through(monkeypatch, tmp_path):
assert json.loads(cache_path.read_text())["secrets"] == {"K1": "v1"}


def test_encrypted_cache_writes_without_plaintext(monkeypatch, tmp_path):
"""Encrypted cache stores last-good secrets without raw values on disk."""
home = tmp_path / ".hermes"
home.mkdir()
fake_binary = tmp_path / "bws"
fake_binary.write_text("")
payload = _fake_bws_payload([{"key": "K1", "value": "secret-value"}])

monkeypatch.setattr(
bw.subprocess,
"run",
lambda *a, **kw: mock.Mock(returncode=0, stdout=payload, stderr=""),
)
bw._reset_cache_for_tests(home)
# A successful encrypted write must remove a pre-existing legacy plaintext
# cache from the migration path.
legacy_key = (bw._token_fingerprint("0.t"), "proj-1", "")
bw._DISK_CACHE.write(
legacy_key,
bw._CachedFetch(secrets={"K1": "legacy"}, fetched_at=time.time()),
300,
home,
)
assert bw._disk_cache_path(home).exists()

secrets, warnings = bw.fetch_bitwarden_secrets(
access_token="0.t", project_id="proj-1", binary=fake_binary,
cache_ttl_seconds=0, encrypted_cache_enabled=True,
encrypted_cache_max_stale_seconds=604800, home_path=home,
)

assert secrets == {"K1": "secret-value"}
assert warnings == []
assert not bw._disk_cache_path(home).exists()
cache_path = bw._encrypted_disk_cache_path(home)
assert cache_path.exists()
mode = stat.S_IMODE(os.stat(cache_path).st_mode)
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
text = cache_path.read_text()
assert "secret-value" not in text
assert "0.t" not in text
payload_disk = json.loads(text)
assert set(payload_disk.keys()) == {
"version", "key", "salt", "nonce", "ciphertext",
}
assert not bw._disk_cache_path(home).exists()


def test_encrypted_cache_enabled_never_writes_plaintext_when_stale_disabled(
monkeypatch, tmp_path
):
"""Encryption remains mandatory even when stale fallback is disabled."""
home = tmp_path / ".hermes"
home.mkdir()
fake_binary = tmp_path / "bws"
fake_binary.write_text("")
monkeypatch.setattr(
bw.subprocess,
"run",
lambda *a, **kw: mock.Mock(
returncode=0,
stdout=_fake_bws_payload([{"key": "K1", "value": "secret-value"}]),
stderr="",
),
)

bw.fetch_bitwarden_secrets(
access_token="0.t",
project_id="proj-1",
binary=fake_binary,
cache_ttl_seconds=300,
encrypted_cache_enabled=True,
encrypted_cache_max_stale_seconds=0,
home_path=home,
)

assert bw._encrypted_disk_cache_path(home).exists()
assert not bw._disk_cache_path(home).exists()


def test_encrypted_cache_timestamp_is_authenticated(monkeypatch, tmp_path):
"""An unauthenticated outer timestamp cannot make old ciphertext usable."""
home = tmp_path / ".hermes"
home.mkdir()
fake_binary = tmp_path / "bws"
fake_binary.write_text("")
calls = {"n": 0}

def fake_run(*a, **kw):
calls["n"] += 1
if calls["n"] == 1:
return mock.Mock(
returncode=0,
stdout=_fake_bws_payload([{"key": "K1", "value": "cached"}]),
stderr="",
)
return mock.Mock(
returncode=1,
stdout="",
stderr="Error: network is unreachable",
)

monkeypatch.setattr(bw.subprocess, "run", fake_run)
bw.fetch_bitwarden_secrets(
access_token="0.t",
project_id="proj-1",
binary=fake_binary,
cache_ttl_seconds=0,
encrypted_cache_enabled=True,
encrypted_cache_max_stale_seconds=300,
home_path=home,
)

cache_path = bw._encrypted_disk_cache_path(home)
payload = json.loads(cache_path.read_text())
cache_key = (bw._token_fingerprint("0.t"), "proj-1", "")
serialized_key = bw._cache_key_str(cache_key)
key = bw._derive_encrypted_cache_key("0.t", bw._b64d(payload["salt"]))
inner = json.loads(
bw.AESGCM(key).decrypt(
bw._b64d(payload["nonce"]),
bw._b64d(payload["ciphertext"]),
serialized_key.encode("utf-8"),
).decode("utf-8")
)
inner["fetched_at"] = time.time() - 10_000
nonce = os.urandom(12)
payload["nonce"] = bw._b64e(nonce)
payload["ciphertext"] = bw._b64e(
bw.AESGCM(key).encrypt(
nonce,
json.dumps(inner, separators=(",", ":")).encode("utf-8"),
serialized_key.encode("utf-8"),
)
)
# Simulate the old vulnerable format: a fresh, unauthenticated outer
# timestamp alongside stale encrypted content.
payload["fetched_at"] = time.time()
cache_path.write_text(json.dumps(payload))
bw._CACHE.clear()

with pytest.raises(RuntimeError, match="network is unreachable"):
bw.fetch_bitwarden_secrets(
access_token="0.t",
project_id="proj-1",
binary=fake_binary,
cache_ttl_seconds=0,
encrypted_cache_enabled=True,
encrypted_cache_max_stale_seconds=300,
home_path=home,
)
def test_encrypted_cache_falls_back_on_network_error(monkeypatch, tmp_path):
"""A fresh-enough encrypted cache is used when BWS is unreachable."""
home = tmp_path / ".hermes"
home.mkdir()
fake_binary = tmp_path / "bws"
fake_binary.write_text("")
calls = {"n": 0}

def fake_run(*a, **kw):
calls["n"] += 1
if calls["n"] == 1:
return mock.Mock(
returncode=0,
stdout=_fake_bws_payload([{"key": "K1", "value": "cached"}]),
stderr="",
)
return mock.Mock(
returncode=1,
stdout="",
stderr="Error: network is unreachable",
)

monkeypatch.setattr(bw.subprocess, "run", fake_run)
bw._reset_cache_for_tests(home)

first, _ = bw.fetch_bitwarden_secrets(
access_token="0.t", project_id="proj-1", binary=fake_binary,
cache_ttl_seconds=0, encrypted_cache_enabled=True,
encrypted_cache_max_stale_seconds=604800, home_path=home,
)
assert first == {"K1": "cached"}
bw._CACHE.clear()

second, warnings = bw.fetch_bitwarden_secrets(
access_token="0.t", project_id="proj-1", binary=fake_binary,
cache_ttl_seconds=0, encrypted_cache_enabled=True,
encrypted_cache_max_stale_seconds=604800, home_path=home,
)
assert second == {"K1": "cached"}
assert calls["n"] == 2
assert len(warnings) == 1
assert "stale ENCRYPTED disk cache" in warnings[0]
assert "bws live fetch failed" in warnings[0]


def test_encrypted_cache_does_not_fallback_on_auth_failure(monkeypatch, tmp_path):
"""Auth failures must not bypass revocation by using stale secrets."""
home = tmp_path / ".hermes"
home.mkdir()
fake_binary = tmp_path / "bws"
fake_binary.write_text("")
calls = {"n": 0}

def fake_run(*a, **kw):
calls["n"] += 1
if calls["n"] == 1:
return mock.Mock(
returncode=0,
stdout=_fake_bws_payload([{"key": "K1", "value": "cached"}]),
stderr="",
)
return mock.Mock(returncode=1, stdout="", stderr="Error: invalid access token")

monkeypatch.setattr(bw.subprocess, "run", fake_run)
bw._reset_cache_for_tests(home)

bw.fetch_bitwarden_secrets(
access_token="0.t", project_id="proj-1", binary=fake_binary,
cache_ttl_seconds=0, encrypted_cache_enabled=True,
encrypted_cache_max_stale_seconds=604800, home_path=home,
)
bw._CACHE.clear()

with pytest.raises(RuntimeError, match="invalid access token"):
bw.fetch_bitwarden_secrets(
access_token="0.t", project_id="proj-1", binary=fake_binary,
cache_ttl_seconds=0, encrypted_cache_enabled=True,
encrypted_cache_max_stale_seconds=604800, home_path=home,
)


def test_reset_cache_for_tests_deletes_disk_file(tmp_path):
"""_reset_cache_for_tests(home_path) must also clean disk."""
home = tmp_path / ".hermes"
home.mkdir()
cache_path = bw._disk_cache_path(home)
encrypted_cache_path = bw._encrypted_disk_cache_path(home)
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text("{}")
encrypted_cache_path.write_text("{}")
assert cache_path.exists()
assert encrypted_cache_path.exists()

bw._reset_cache_for_tests(home)
assert not cache_path.exists()
assert not encrypted_cache_path.exists()
# Idempotent
bw._reset_cache_for_tests(home)

Expand Down
6 changes: 6 additions & 0 deletions tests/tools/test_write_deny.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ def test_hermes_env(self):
path = str(get_hermes_home() / ".env")
assert _is_write_denied(path) is True

def test_encrypted_bitwarden_cache(self):
from hermes_constants import get_hermes_home

path = get_hermes_home() / "cache" / "bws_cache.enc.json"
assert _is_write_denied(str(path)) is True

def test_hermes_root_env_when_running_under_profile(self, tmp_path, monkeypatch):
"""Top-level ``<root>/.env`` stays write-denied even when running under
a profile (#15981).
Expand Down
7 changes: 6 additions & 1 deletion website/docs/user-guide/secrets/bitwarden.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ secrets:
project_id: ""
server_url: ""
cache_ttl_seconds: 300
encrypted_cache:
enabled: false
max_stale_seconds: 0
override_existing: true
auto_install: true
```
Expand All @@ -115,7 +118,9 @@ secrets:
| `access_token_env` | `BWS_ACCESS_TOKEN` | Env var name that holds the bootstrap token. Change this if you already use `BWS_ACCESS_TOKEN` for something else. |
| `project_id` | `""` | UUID of the project to sync from. |
| `server_url` | `""` | Bitwarden region or self-hosted endpoint. Empty = `bws` default (US Cloud, `https://vault.bitwarden.com`). Set to `https://vault.bitwarden.eu` for EU Cloud, or your own URL for self-hosted. Plumbed into the `bws` subprocess as `BWS_SERVER_URL`. |
| `cache_ttl_seconds` | `300` | How long an in-process fetch result is reused. Set to `0` to disable caching. Cache is per-process; new `hermes` invocations start fresh. |
| `cache_ttl_seconds` | `300` | How long an in-process or disk fetch result is reused. Set to `0` to disable fresh-cache reuse. |
| `encrypted_cache.enabled` | `false` | Store the last successful fetch in an AES-GCM encrypted cache at `~/.hermes/cache/bws_cache.enc.json`. |
| `encrypted_cache.max_stale_seconds` | `0` | When encrypted caching is enabled, allow that cache to be used only after network/timeout failures, up to this age. Authentication failures never use stale secrets. A successful encrypted write removes the legacy plaintext `cache/bws_cache.json`. |
| `override_existing` | `true` | When true, Bitwarden values overwrite anything already in env (so rotation in the web app actually takes effect). Flip to `false` if you want `.env` / shell exports to win locally. |
| `auto_install` | `true` | When true, `bws` is auto-downloaded into `~/.hermes/bin/` on first use. |

Expand Down
Loading