"""Bitwarden Secrets Manager (`bws` CLI) integration. Hermes pulls API keys from Bitwarden Secrets Manager at startup so they don't have to live in plaintext in ``~/.hermes/.env``. * ``bws`` is auto-installed into ``/bin/bws`` on first use: one pinned version (``_BWS_VERSION``) downloaded from the official GitHub release and SHA-256-verified against the published checksum file. * The one bootstrap secret is the access token in ``.env`` (``BWS_ACCESS_TOKEN`` or ``secrets.bitwarden.access_token_env``); every other key can live in BSM. * One ``bws secret list --output json`` call per fetch, cached in-process and on disk for ``cache_ttl_seconds``. * Failures NEVER block startup: a one-line warning, then continue with .env. Subprocess-driven on purpose: one cross-platform binary is easier to lazy-install than the ``bitwarden-sdk-secrets`` Rust-extension wheel. """ from __future__ import annotations import base64 import hashlib import json import logging import os import platform import re import shutil import subprocess import tempfile import time import urllib.error import urllib.request import zipfile from pathlib import Path from typing import Dict, List, Optional, Tuple from agent.secret_sources._cache import ( CachedFetch as _CachedFetch, SecretCache, atomic_write_json, entry_from_payload, resolve_cache_home, ) from agent.secret_sources.base import ( ErrorKind, FetchResult, SecretSource, classify_cli_error, coerce_float, is_valid_env_name as _is_valid_env_name, get_source_environment, run_cli, source_child_env, ) logger = logging.getLogger(__name__) # Pinned upstream version — never auto-resolve "latest": release shape (asset # names, CLI flags) may change between majors and updates must be deliberate. _BWS_VERSION = "2.0.0" _BWS_RELEASE_BASE = ( f"https://github.com/bitwarden/sdk-sm/releases/download/bws-v{_BWS_VERSION}" ) _BWS_CHECKSUM_NAME = f"bws-sha256-checksums-{_BWS_VERSION}.txt" _BWS_DOWNLOAD_TIMEOUT = 60 _BWS_RUN_TIMEOUT = 30 # Cache layout: /cache/bws_cache.json holds only secret VALUES # (never the access token) — plaintext-equivalent to .env but kept out of it so # users editing .env don't commit BSM-sourced secrets. _CacheKey = Tuple[str, str, str] # (access_token_fingerprint, project_id, server_url) _DISK_CACHE_BASENAME = "bws_cache.json" _ENCRYPTED_CACHE_BASENAME = "bws_cache.enc.json" _ENCRYPTED_CACHE_VERSION = 1 _ENCRYPTED_CACHE_INFO = b"hermes-bws-encrypted-cache-v1" def _cache_key_str(cache_key: _CacheKey) -> str: token_fp, project_id, server_url = cache_key return f"{token_fp}|{project_id}|{server_url}" _STORE: SecretCache[_CacheKey] = SecretCache(_DISK_CACHE_BASENAME, key_serializer=_cache_key_str) # Test seams: L1 dict, L2 DiskCache, and its path. _CACHE = _STORE.memory _DISK_CACHE = _STORE.disk _disk_cache_path = _DISK_CACHE.path def _encrypted_disk_cache_path(home_path: Optional[Path] = None) -> Path: return resolve_cache_home(home_path) / "cache" / _ENCRYPTED_CACHE_BASENAME # --------------------------------------------------------------------------- # Binary discovery + lazy install # --------------------------------------------------------------------------- def _hermes_bin_dir() -> Path: """Where Hermes stores its managed binaries. Profile-aware.""" from hermes_constants import get_hermes_home return get_hermes_home() / "bin" def find_bws(*, install_if_missing: bool = False) -> Optional[Path]: """Managed ``/bin/bws`` first, then PATH, then optional auto-install.""" managed = _hermes_bin_dir() / _platform_binary_name() if managed.exists() and os.access(managed, os.X_OK): return managed system = shutil.which("bws") if system: return Path(system) if install_if_missing: try: return install_bws() except Exception as exc: # noqa: BLE001 — never block startup logger.warning("bws auto-install failed: %s", exc) return None return None def _platform_binary_name() -> str: return "bws.exe" if platform.system() == "Windows" else "bws" def _platform_asset_name() -> str: """Map (uname, arch, libc) → upstream asset filename (Rust target-triple style).""" system = platform.system() machine = platform.machine().lower() arch = "aarch64" if machine in ("arm64", "aarch64") else "x86_64" if system == "Darwin": # universal binary covers Intel + Apple Silicon return f"bws-macos-universal-{_BWS_VERSION}.zip" if system == "Windows": return f"bws-{arch}-pc-windows-msvc-{_BWS_VERSION}.zip" if system == "Linux": # glibc default; musl only if ldd says so (glibc prints to stderr, musl # to stdout). A wrong guess surfaces as a loader error we catch. libc = "gnu" try: res = subprocess.run( ["ldd", "--version"], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=2, stdin=subprocess.DEVNULL, ) if "musl" in (res.stdout + res.stderr).lower(): libc = "musl" except (OSError, subprocess.TimeoutExpired): pass return f"bws-{arch}-unknown-linux-{libc}-{_BWS_VERSION}.zip" raise RuntimeError( f"Unsupported platform for bws auto-install: {system} {machine}" ) def install_bws(*, force: bool = False) -> Path: """Download, verify, and install the pinned ``bws`` binary; raises on any failure. The auto-install path catches; ``hermes secrets bitwarden setup`` lets the error propagate so the wizard can show it. """ bin_dir = _hermes_bin_dir() bin_dir.mkdir(parents=True, exist_ok=True) target = bin_dir / _platform_binary_name() if target.exists() and not force: return target asset_name = _platform_asset_name() asset_url = f"{_BWS_RELEASE_BASE}/{asset_name}" checksum_url = f"{_BWS_RELEASE_BASE}/{_BWS_CHECKSUM_NAME}" with tempfile.TemporaryDirectory(prefix="hermes-bws-") as tmpdir: tmp = Path(tmpdir) zip_path = tmp / asset_name checksum_path = tmp / _BWS_CHECKSUM_NAME logger.info("Downloading %s", asset_url) _http_download(asset_url, zip_path) _http_download(checksum_url, checksum_path) expected = _expected_sha256(checksum_path, asset_name) actual = _sha256_file(zip_path) if expected.lower() != actual.lower(): raise RuntimeError( f"Checksum mismatch for {asset_name}: " f"expected {expected}, got {actual}" ) with zipfile.ZipFile(zip_path) as zf: member = _pick_zip_member(zf, _platform_binary_name()) extracted = _safe_extract_member(zf, member, tmp) # Stage in the final directory so the rename can't cross filesystems. fd, staged = tempfile.mkstemp(dir=str(bin_dir), prefix=".bws_") os.close(fd) shutil.copy2(extracted, staged) os.chmod(staged, 0o755) os.replace(staged, target) logger.info("Installed bws %s at %s", _BWS_VERSION, target) return target def _http_download(url: str, dest: Path) -> None: req = urllib.request.Request(url, headers={"User-Agent": "hermes-agent"}) try: with urllib.request.urlopen(req, timeout=_BWS_DOWNLOAD_TIMEOUT) as resp: # noqa: S310 with open(dest, "wb") as f: shutil.copyfileobj(resp, f) except urllib.error.URLError as exc: raise RuntimeError(f"Failed to download {url}: {exc}") from exc def _expected_sha256(checksum_file: Path, asset_name: str) -> str: """Parse standard ``sha256sum`` output (`` `` per line).""" text = checksum_file.read_text(encoding="utf-8", errors="replace") for line in text.splitlines(): parts = line.strip().split() if len(parts) >= 2 and parts[-1] == asset_name: return parts[0] raise RuntimeError( f"No checksum entry for {asset_name} in {checksum_file.name}" ) def _sha256_file(path: Path) -> str: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) return h.hexdigest() def _pick_zip_member(zf: zipfile.ZipFile, binary_name: str) -> str: """Find the binary in the zip; tolerate a top-level dir, prefer the shortest path.""" candidates = [n for n in zf.namelist() if n.split("/")[-1] == binary_name] if not candidates: raise RuntimeError( f"Could not find {binary_name} inside downloaded archive " f"(members: {zf.namelist()[:5]}...)" ) candidates.sort(key=len) return candidates[0] def _safe_extract_member( zf: zipfile.ZipFile, member: str, dest_dir: Path ) -> Path: """Extract one member, refusing zip-slip (``../`` or absolute member names). ``ZipFile.extract`` joins the member onto ``dest_dir`` without verifying the result stays inside it, so containment is checked here first. """ dest_root = os.path.realpath(dest_dir) target = os.path.realpath(os.path.join(dest_root, member)) try: # commonpath raises for e.g. different Windows drives — treat as escape contained = os.path.commonpath([dest_root, target]) == dest_root except ValueError: contained = False if not contained or target == dest_root: raise RuntimeError( f"Refusing to extract unsafe archive member {member!r}: " f"it escapes the extraction directory" ) zf.extract(member, dest_root) return Path(target) # --------------------------------------------------------------------------- # Encrypted last-good cache (opt-in) # --------------------------------------------------------------------------- def _token_fingerprint(token: str) -> str: """SHA-256 prefix used as a cache key — never logged, never displayed.""" return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16] def _b64e(raw: bytes) -> str: return base64.b64encode(raw).decode("ascii") def _b64d(text: str) -> bytes: return base64.b64decode(text.encode("ascii"), validate=True) def _derive_encrypted_cache_key(access_token: str, salt: bytes) -> bytes: """HKDF the local cache key from the bootstrap BWS token. cryptography is imported lazily: most CLI commands import this module while building argparse, and eagerly mapping ``_rust.pyd`` on Windows blocks the updater from replacing that file. """ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.hkdf import HKDF return HKDF( algorithm=hashes.SHA256(), length=32, salt=salt, info=_ENCRYPTED_CACHE_INFO, ).derive(access_token.encode("utf-8")) def _write_encrypted_disk_cache( *, cache_key: _CacheKey, access_token: str, entry: _CachedFetch, home_path: Optional[Path] = None, ) -> None: """Persist an AES-GCM encrypted last-good entry atomically (best-effort). The raw token is never stored; it only derives the key. A successful write completes migration, so the legacy plaintext cache is removed. """ try: from cryptography.hazmat.primitives.ciphers.aead import AESGCM salt = os.urandom(16) nonce = os.urandom(12) serialized_key = _cache_key_str(cache_key) key = _derive_encrypted_cache_key(access_token, salt) plaintext = json.dumps( {"secrets": entry.secrets, "fetched_at": entry.fetched_at}, separators=(",", ":"), ).encode("utf-8") ciphertext = AESGCM(key).encrypt( nonce, plaintext, serialized_key.encode("utf-8") ) payload = { "version": _ENCRYPTED_CACHE_VERSION, "key": serialized_key, "salt": _b64e(salt), "nonce": _b64e(nonce), "ciphertext": _b64e(ciphertext), } atomic_write_json(_encrypted_disk_cache_path(home_path), payload, tmp_prefix=".bws_cache_enc_") _STORE.disk.clear(home_path) except Exception: # noqa: BLE001 — best-effort cache only return def _read_encrypted_disk_cache( *, cache_key: _CacheKey, access_token: str, max_age_seconds: float, home_path: Optional[Path] = None, ) -> Optional[_CachedFetch]: """Decrypted encrypted-cache entry if it matches ``cache_key`` and is in-window.""" if max_age_seconds <= 0: return None try: from cryptography.hazmat.primitives.ciphers.aead import AESGCM payload = json.loads(_encrypted_disk_cache_path(home_path).read_text(encoding="utf-8")) serialized_key = _cache_key_str(cache_key) if (not isinstance(payload, dict) or payload.get("version") != _ENCRYPTED_CACHE_VERSION or payload.get("key") != serialized_key): return None salt = _b64d(str(payload.get("salt", ""))) nonce = _b64d(str(payload.get("nonce", ""))) ciphertext = _b64d(str(payload.get("ciphertext", ""))) key = _derive_encrypted_cache_key(access_token, salt) entry = entry_from_payload(json.loads(AESGCM(key).decrypt( nonce, ciphertext, serialized_key.encode("utf-8") ).decode("utf-8"))) if entry is None: return None entry_age = time.time() - entry.fetched_at if entry_age < 0 or entry_age > max_age_seconds: return None return entry except Exception: # noqa: BLE001 — cache miss on parse/decrypt/I/O errors return None # --------------------------------------------------------------------------- # Secret fetch # --------------------------------------------------------------------------- def fetch_bitwarden_secrets( *, access_token: str, project_id: str, binary: Optional[Path] = None, cache_ttl_seconds: float = 300, use_cache: bool = True, server_url: str = "", home_path: Optional[Path] = None, encrypted_cache_enabled: bool = False, encrypted_cache_max_stale_seconds: float = 0, ) -> Tuple[Dict[str, str], List[str]]: """Pull the secrets for ``project_id`` from BSM → ``(secrets, warnings)``. ``server_url`` selects a region / self-hosted instance (``BWS_SERVER_URL``; empty = bws default, US Cloud). ``cache_ttl_seconds`` governs the fresh cache. With ``encrypted_cache_enabled`` fresh entries are written AES-GCM encrypted instead of plaintext, and a last-good entry may be served after NETWORK/TIMEOUT failures for up to ``encrypted_cache_max_stale_seconds`` — independent of the fresh TTL, so ``cache_ttl_seconds: 0`` can coexist with a break-glass offline cache. Raises ``RuntimeError`` for fatal conditions (missing binary, auth failure, unparseable output); the env_loader path catches, the setup wizard lets it propagate. """ if not access_token: raise RuntimeError("Bitwarden access token is empty") if not project_id: raise RuntimeError("Bitwarden project_id is empty") cache_key = (_token_fingerprint(access_token), project_id, server_url or "") def _read_encrypted(max_age: float) -> Optional[_CachedFetch]: return _read_encrypted_disk_cache( cache_key=cache_key, access_token=access_token, max_age_seconds=max_age, home_path=home_path, ) if use_cache and cache_ttl_seconds > 0: # L2 (~5ms) vs ~380ms for `bws secret list`. cached = _STORE.lookup( cache_key, cache_ttl_seconds, home_path, read_disk=(lambda: _read_encrypted(cache_ttl_seconds)) if encrypted_cache_enabled else None, ) if cached is not None: return cached.secrets, [] bws = binary or find_bws(install_if_missing=True) if bws is None: raise RuntimeError( "bws binary not available — auto-install failed and `bws` is " "not on PATH. Install manually from " "https://github.com/bitwarden/sdk-sm/releases or re-run " "`hermes secrets bitwarden setup`." ) try: secrets, warnings = _run_bws_list(bws, access_token, project_id, server_url) except RuntimeError as exc: # Stale fallback ONLY for transport failures — never AUTH_FAILED or a # malformed-output INTERNAL error, where serving old secrets would mask # a real config/credential problem. Without it a fleet sharing one BSM # project all stops on a single network blip. # When the encrypted cache is enabled it is the ONLY fallback consulted # (the at-rest payload must never be plaintext). Otherwise the plain # DiskCache is read with ttl=inf (a stale hit is the point) but only if # the caller's real TTL > 0 — ttl<=0 means caching is opted out. kind = _classify_bws_error(str(exc)) if use_cache and kind in (ErrorKind.NETWORK, ErrorKind.TIMEOUT): stale = label = None if encrypted_cache_enabled: stale = _read_encrypted(encrypted_cache_max_stale_seconds) label = "stale ENCRYPTED disk cache" elif cache_ttl_seconds > 0: stale = _STORE.disk.read(cache_key, float("inf"), home_path) label = "stale disk cache" if stale is not None: age = max(0.0, time.time() - stale.fetched_at) _STORE.memory[cache_key] = stale return stale.secrets, [ f"bws live fetch failed ({exc}); falling back to {label} ({int(age)}s old)" ] raise entry = _CachedFetch(secrets=secrets, fetched_at=time.time()) if use_cache: if cache_ttl_seconds > 0: _STORE.memory[cache_key] = entry if encrypted_cache_enabled: # Encryption is the storage policy; max_stale only gates outage # reads. Never fall back to plaintext because stale fallback is off. _write_encrypted_disk_cache( cache_key=cache_key, access_token=access_token, entry=entry, home_path=home_path, ) else: _STORE.disk.write(cache_key, entry, cache_ttl_seconds, home_path) return secrets, warnings def _summarize_bws_stderr(raw: str) -> str: """Reduce a bws (Rust color-eyre) error dump to its cause line(s). Keeps the numbered ``0: ...`` cause lines (joined with ``; ``), drops everything from ``Location:``/``Backtrace omitted`` on, and falls back to the stripped raw text when the shape is unrecognized. """ text = raw.replace("\x1b", "").strip() if not text: return text causes: List[str] = [] for line in text.splitlines(): stripped = line.strip() if stripped.startswith(("Location:", "Backtrace omitted", "Run with ")): break if stripped in ("", "Error:"): continue stripped = re.sub(r"^\d+:\s*", "", stripped) if stripped: causes.append(stripped) return "; ".join(causes) if causes else text def _run_bws_list( bws: Path, access_token: str, project_id: str, server_url: str = "" ) -> Tuple[Dict[str, str], List[str]]: cmd = [str(bws), "secret", "list", project_id, "--output", "json"] # The bws child intentionally receives the access token; a profile-local # fetch must not inherit sibling credentials (source_child_env). env = source_child_env() env["BWS_ACCESS_TOKEN"] = access_token env.setdefault("NO_COLOR", "1") # Empty server_url keeps whatever BWS_SERVER_URL the shell already had. if server_url: env["BWS_SERVER_URL"] = server_url proc = run_cli(cmd, env=env, timeout=_BWS_RUN_TIMEOUT, label="bws", timeout_message=f"bws timed out after {_BWS_RUN_TIMEOUT}s fetching secrets") if proc.returncode != 0: err = _summarize_bws_stderr(proc.stderr or proc.stdout or "") raise RuntimeError( f"bws exited {proc.returncode}: {err[:200]}" ) raw = proc.stdout.strip() if not raw: return {}, ["bws returned no output (empty project?)"] try: payload = json.loads(raw) except json.JSONDecodeError as exc: raise RuntimeError(f"bws returned non-JSON output: {exc}") from exc if not isinstance(payload, list): raise RuntimeError( f"bws returned unexpected shape: {type(payload).__name__}" ) secrets: Dict[str, str] = {} warnings: List[str] = [] for item in payload: if not isinstance(item, dict): continue key = item.get("key") value = item.get("value") if not isinstance(key, str) or not isinstance(value, str): continue if not _is_valid_env_name(key): warnings.append( f"Skipping secret {key!r}: not a valid env-var name" ) continue secrets[key] = value return secrets, warnings # --------------------------------------------------------------------------- # SecretSource adapter — the registry-facing wrapper around this module. # --------------------------------------------------------------------------- class BitwardenSource(SecretSource): """Bitwarden Secrets Manager as a registered **bulk** source. ``fetch()`` only fetches — precedence, overrides and the ``os.environ`` writes are the orchestrator's. Bulk: it injects every secret in the BSM project, so explicit per-var bindings from mapped sources outrank it. """ name = "bitwarden" label = "Bitwarden Secrets Manager" shape = "bulk" scheme = "bws" token_env_key = "access_token_env" default_token_env = "BWS_ACCESS_TOKEN" # override_existing defaults True: the point of BSM is centralized rotation # — a stale .env line must not have the final say. override_existing_default = True def config_schema(self) -> dict: return { "enabled": {"description": "Master switch", "default": False}, "access_token_env": { "description": "Env var holding the machine-account access token", "default": "BWS_ACCESS_TOKEN", }, "project_id": {"description": "BSM project UUID", "default": ""}, "cache_ttl_seconds": { "description": "Fresh disk+memory cache TTL; 0 disables fresh-cache reuse", "default": 300, }, "encrypted_cache": { "description": "Encrypted last-good cache for network/timeout fallback", "default": {"enabled": False, "max_stale_seconds": 0}, }, "override_existing": {"description": "BSM values overwrite .env/shell values", "default": True}, "auto_install": {"description": "Auto-download the pinned bws binary", "default": True}, "server_url": {"description": "Region / self-hosted endpoint (empty = US Cloud)", "default": ""}, } def fetch(self, cfg: dict, home_path: Path) -> FetchResult: cfg = cfg if isinstance(cfg, dict) else {} result = FetchResult() access_token_env = self.token_env(cfg) access_token = get_source_environment().get(access_token_env, "").strip() if not access_token: return result.fail( f"secrets.bitwarden.enabled is true but {access_token_env} is " "not set. Run `hermes secrets bitwarden setup`.", ErrorKind.NOT_CONFIGURED, ) project_id = str(cfg.get("project_id") or "") if not project_id: return result.fail( "secrets.bitwarden.project_id is empty. " "Run `hermes secrets bitwarden setup`.", ErrorKind.NOT_CONFIGURED, ) binary = find_bws(install_if_missing=bool(cfg.get("auto_install", True))) result.binary_path = binary if binary is None: return result.fail( "bws binary not available and auto-install is disabled. " "Run `hermes secrets bitwarden setup` to install.", ErrorKind.BINARY_MISSING, ) encrypted_cfg = cfg.get("encrypted_cache") encrypted_cfg = encrypted_cfg if isinstance(encrypted_cfg, dict) else {} try: secrets, warnings = fetch_bitwarden_secrets( access_token=access_token, project_id=project_id, binary=binary, cache_ttl_seconds=coerce_float(cfg.get("cache_ttl_seconds", 300), 300.0), server_url=str(cfg.get("server_url", "") or "").strip(), home_path=home_path, encrypted_cache_enabled=bool(encrypted_cfg.get("enabled", False)), encrypted_cache_max_stale_seconds=coerce_float( encrypted_cfg.get("max_stale_seconds", 0), 0.0), ) except RuntimeError as exc: result.fail(str(exc), _classify_bws_error(str(exc))) if result.error_kind == ErrorKind.AUTH_FAILED: # Say what the raw OAuth reject means for the user first. result.error = ( "Bitwarden rejected the machine-account access token " f"({access_token_env}) — it was likely revoked, expired, " f"or belongs to another region. ({result.error})" ) return result result.secrets = secrets result.warnings.extend(warnings) return result def remediation(self, kind, cfg: dict) -> str: if kind in (ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED): return ( "Run `hermes secrets bitwarden token` to paste a fresh access " "token (create one in the Bitwarden web app: Secrets Manager → " "Machine accounts → Access tokens). Wrong region? Re-run " "`hermes secrets bitwarden setup` and pick EU/self-hosted." ) return super().remediation(kind, cfg) # First matching rule wins. The BSM identity endpoint rejects a revoked / # expired machine-account token with an OAuth-style # `[400 Bad Request] {"error":"invalid_client"}`, hence those AUTH tokens. _BWS_ERROR_RULES = ( (ErrorKind.TIMEOUT, ("timed out",)), (ErrorKind.BINARY_MISSING, ("binary not available", "failed to invoke")), (ErrorKind.AUTH_FAILED, ("unauthorized", "invalid token", "access token", "401", "403", "invalid_client", "invalid_grant", "400 bad request")), (ErrorKind.NETWORK, ("network", "connection", "resolve", "download", "dns")), ) def _classify_bws_error(message: str) -> ErrorKind: return classify_cli_error(message, _BWS_ERROR_RULES) def clear_caches(home_path: Optional[Path] = None) -> None: """Drop in-process AND disk caches (plaintext and encrypted). Used after a token rotation so the next startup fetches fresh instead of serving a pull cached under the old token's fingerprint. """ _STORE.clear(home_path) try: _encrypted_disk_cache_path(home_path).unlink() except (FileNotFoundError, OSError): pass _reset_cache_for_tests = clear_caches