Files
hermes-agent/agent/secret_sources/bitwarden.py
Teknium a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
2026-09-28 10:21:41 -07:00

400 lines
18 KiB
Python

"""Bitwarden Secrets Manager (`bws` CLI) integration.
Pulls API keys from BSM at startup so they need not live in ``~/.hermes/.env``.
``bws`` is auto-installed into the PM tool store (one pinned version,
SHA-256-verified against the published checksum). The one bootstrap secret is
the access token in ``.env``; every other key can live in BSM. One
``bws secret list <project_id>`` call per fetch, cached in-process and on disk
for ``cache_ttl_seconds``. Failures NEVER block startup. Subprocess-driven on
purpose: one cross-platform binary beats the ``bitwarden-sdk-secrets`` Rust wheel.
"""
from __future__ import annotations
import base64
import json
import logging
import os
import re
import shutil
import time
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,
fingerprint as _token_fingerprint, 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__)
_BWS_RUN_TIMEOUT = 30
# <hermes_home>/cache/bws_cache.json holds only secret VALUES (never the access
# token); kept out of .env 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:
return "|".join(cache_key)
_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
# 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)
# --- Binary discovery + lazy install ----------------------------------------
def find_bws(*, install_if_missing: bool = False) -> Optional[Path]:
"""External tools do not require PM platform support; acquire only on a miss."""
if system := shutil.which("bws"):
return Path(system)
import pm
selected = pm.installed_package("bws")
if selected is not None:
return selected.binary
if install_if_missing:
try:
pm.ensure("bws")
return pm.installed_package("bws").binary
except Exception as exc: # noqa: BLE001 — never block startup
logger.warning("bws auto-install failed: %s", exc)
return None
def install_bws(*, force: bool = False) -> Path:
"""Explicit setup/repair; PM re-verifies even a warm entry (including force)."""
import pm
pm.ensure("bws", explicit=True)
return pm.installed_package("bws").binary
# --- Encrypted last-good cache (opt-in) -------------------------------------
def _b64e(raw: bytes) -> str:
return base64.b64encode(raw).decode("ascii")
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: eagerly mapping ``_rust.pyd`` on Windows blocks the updater replacing it."""
# Keep the native cryptography extension lazy. Most CLI commands import this module while building
# argparse, even though only encrypted-cache reads/writes need it. See #73381.
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 only derives the key; a successful write removes the legacy plaintext cache."""
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)
_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-sig"))
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, nonce, ciphertext = (base64.b64decode(str(payload.get(k, "")).encode("ascii"), validate=True)
for k in ("salt", "nonce", "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
return None if entry_age < 0 or entry_age > max_age_seconds else 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``: region / self-hosted instance (empty = US Cloud). With
``encrypted_cache_enabled`` fresh entries are written AES-GCM encrypted 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`` on fatal conditions (missing binary, auth failure,
unparseable output); env_loader 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 / INTERNAL,
# where old secrets would mask a real problem (without it a fleet sharing
# one project all stops on a network blip). With the encrypted cache on it
# is the ONLY fallback (at-rest payload must never be plaintext); else the
# plain DiskCache is read with ttl=inf, but only when the real TTL > 0.
if use_cache and _classify_bws_error(str(exc)) 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: # storage policy; max_stale only gates outage reads
_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 (color-eyre) error dump to its numbered cause lines joined with
``; `` (dropping ``Location:``/``Backtrace`` on); raw text if unrecognized."""
text = raw.replace("\x1b", "").strip()
causes: List[str] = []
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith(("Location:", "Backtrace omitted", "Run with ")):
break
if stripped not in ("", "Error:") and (cause := re.sub(r"^\d+:\s*", "", stripped)):
causes.append(cause)
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")
if server_url: # empty keeps whatever BWS_SERVER_URL the shell already had
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:
key, value = (item.get("key"), item.get("value")) if isinstance(item, dict) else (None, None)
if not isinstance(key, str) or not isinstance(value, str):
continue
if _is_valid_env_name(key):
secrets[key] = value
else:
warnings.append(f"Skipping secret {key!r}: not a valid env-var name")
return secrets, warnings
class BitwardenSource(SecretSource):
"""Bitwarden Secrets Manager as a registered **bulk** source (injects every
secret in the project, so explicit mapped bindings 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
_AUTH_HINT = (
"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."
)
remediation_hints = {ErrorKind.AUTH_FAILED: _AUTH_HINT, ErrorKind.AUTH_EXPIRED: _AUTH_HINT}
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 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 clear_caches(home_path: Optional[Path] = None) -> None:
"""Drop in-process AND disk caches (plaintext and encrypted), e.g. after a token rotation."""
_STORE.clear(home_path)
try:
_encrypted_disk_cache_path(home_path).unlink()
except (FileNotFoundError, OSError):
pass
_reset_cache_for_tests = clear_caches