Files
hermes-agent/agent/vault_backends/bitwarden.py
Teknium dedc99ec6c fix(vault): ownership and race findings from the second independent review
Manager tokens: lock generation fence (a Lock acknowledged while `bw unlock`
/ `op signin` is still running discards the late token); tokens record the
unlocking gateway session and are released when THAT session ends, not when
any sibling session in the profile is torn down.

1Password: OP_CONNECT_HOST/TOKEN come from the profile's scoped secret store
like the service token (Connect outranks a service token inside op), never
from the launch environment.

Vault RPCs bind params.profile (home + secret scope) so a shared remote
backend serving several profiles locks/lists/unlocks the requested one;
unknown profile → RPC error, not a crash.

Fill target: inspection stamps are `<nonce>:<index>`; a fill resolves only
its own inspection's stamps, so an interleaved second inspection can no
longer redirect A's password into a newly mounted field (real Chrome: 0
filled, both fields empty).

Desktop Settings: every RPC goes through the owner profile's socket
(requestGatewayForProfile), query keys carry (connection, profile), an owner
change closes dialogs and wipes drafts (a master password typed for A is
never submitted to B; a late list from A never paints under B), and vault.add
secrets travel in a ref consumed by the mutationFn instead of mutation
variables. Three owner-routing invariant tests on the real component.

Docs/PR body: session-scoped release, lock-race semantics, bw --passwordenv.
2026-09-10 10:35:07 -07:00

118 lines
5.0 KiB
Python

"""Bitwarden Password Manager logins as a vault backend (``bw`` CLI).
This is the personal/org *password* vault (``bw``), distinct from the
Bitwarden Secrets Manager (``bws``) source that hydrates API keys at startup.
Unlock: ``bw unlock --raw --passwordenv VAR`` (the CLI rejects a piped password) mints a
``BW_SESSION`` token. List: ``bw list items`` filtered to type=1 (login) with
a URI. Resolve: ``bw get password <id>``.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
from pathlib import Path
from typing import Dict, List, Optional
from agent.secret_sources.base import run_cli, scrub_ansi
from agent.vault_backends import unlock as _unlock
from agent.vault_backends.base import LoginBackend, UnlockRequired, run_with_secret_env
from agent.vault_store import VaultItemMeta, normalize_origin
logger = logging.getLogger(__name__)
_TIMEOUT = 30.0
_ENV_KEEP = ("PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "SystemRoot",
"TMPDIR", "TMP", "TEMP", "XDG_CONFIG_HOME", "BITWARDENCLI_APPDATA_DIR")
class BitwardenLoginBackend(LoginBackend):
name = "bitwarden"
display_name = "Bitwarden"
prefix = "bw:"
needs_unlock = True
def __init__(self, cfg: Optional[Dict] = None):
self.cfg = cfg or {}
def _bw(self) -> Path:
explicit = str(self.cfg.get("binary_path") or "")
found = explicit or shutil.which("bw")
if not found:
raise RuntimeError("Bitwarden CLI (bw) not found — install it or set vault.bitwarden.binary_path")
return Path(found)
def _env(self, session_token: Optional[str]) -> Dict[str, str]:
env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ}
env["NO_COLOR"] = "1"
if session_token:
env["BW_SESSION"] = session_token
return env
def is_unlocked(self) -> bool:
return _unlock.is_unlocked(self.name)
def unlock(self, master_password: str) -> None:
# bw refuses a piped password ("Master password is required"); its non-interactive contract is
# --passwordenv: the variable exists only in the child's environment, never in argv or ours.
generation = _unlock.begin_unlock(self.name)
proc = run_with_secret_env([str(self._bw()), "unlock", "--raw", "--nointeraction", "--passwordenv", "HERMES_BW_MASTER"],
env=self._env(None), secret_env="HERMES_BW_MASTER", secret=master_password,
timeout=_TIMEOUT, label="bw")
token = (proc.stdout or "").strip()
if proc.returncode != 0 or not token:
err = scrub_ansi(proc.stderr or "").strip()[:200]
if "not logged in" in err.lower():
err = "not logged in — run `bw login` once in a terminal first"
raise RuntimeError(f"Bitwarden unlock failed: {err or 'no session key'}")
if not _unlock.store_session_token(self.name, token, generation):
raise RuntimeError("Bitwarden was locked while unlocking; try again")
def _run(self, *args: str) -> str:
token = _unlock.get_session_token(self.name)
if not token:
raise UnlockRequired(self)
proc = run_cli([str(self._bw()), *args, "--nointeraction"], env=self._env(token), timeout=_TIMEOUT,
label="bw", timeout_message="bw timed out", stdin=subprocess.DEVNULL)
if proc.returncode != 0:
err = scrub_ansi(proc.stderr or "")
if "locked" in err.lower() or "session" in err.lower():
_unlock.lock(self.name)
raise UnlockRequired(self)
raise RuntimeError(f"bw failed: {err[:200]}")
return proc.stdout or ""
def list_items(self) -> List[VaultItemMeta]:
if not self.is_unlocked():
return []
raw = json.loads(self._run("list", "items") or "[]")
out: List[VaultItemMeta] = []
for item in raw if isinstance(raw, list) else []:
if item.get("type") != 1 or not isinstance(item.get("login"), dict):
continue
login = item["login"]
origin = None
for uri in login.get("uris") or []:
try:
origin = normalize_origin(str(uri.get("uri") or ""))
break
except Exception:
continue
if not origin:
continue
username = str(login.get("username") or "").strip() or None
out.append(VaultItemMeta(
id=f"{self.prefix}{item.get('id')}", kind="login", label=str(item.get("name") or origin),
origin=origin, created_at=str(item.get("creationDate") or ""),
identifier_type="username" if username else None, identifier=username))
return out
def get_meta(self, handle: str) -> Optional[VaultItemMeta]:
return next((m for m in self.list_items() if m.id == handle), None)
def resolve_password(self, handle: str) -> str:
return self._run("get", "password", handle[len(self.prefix):]).rstrip("\r\n")