Files
hermes-agent/agent/vault_backends/bitwarden.py
T
Teknium d9ca9c974d feat(vault): two-factor codes — automatic from a saved authenticator key, otherwise asked for in the user's UI
Follow-up to #106480. Sites that ask for a code after the password stopped
the agent cold: the login classifier excludes one-time-code fields on
purpose (a password must never land in an OTP box) and there was no tool
for the second step, so the only move was to ask in chat.

browser_vault_enter_code
  Fills the one-time code the current page asks for. Two sources, same
  invariant as passwords (the code goes to the page over the supervisor
  socket and never enters model context):
  - a TOTP seed on the login: local vault `otp_secret` (RFC 6238, stdlib,
    verified against the RFC test vectors), 1Password `op item get --otp`,
    Bitwarden `bw get totp`. Nobody is asked.
  - no seed: the surface prompts "Verification code for {site}"; the user
    types what their phone/email/app shows. Enter on empty / Skip declines
    and the tool returns code_declined ("do not ask again this turn").
  no_code_field tells the model the site wants a passkey / hardware key /
  app approval: hand it to the user's device and wait for navigation.
  Per-digit OTP boxes (maxlength=1 pattern) get one digit each in DOM order.

Surfaces
  CLI: sudo-style panel, code shown as typed (not a secret worth masking,
  typos must be visible), Enter submits, ESC/empty skips.
  Desktop: "Verification code for {site}" card via vault.code.request /
  vault.code.respond (gateway), owner-routed like the other vault prompts.
  Settings → Passwords & Logins: optional "Authenticator key" field on the
  add form (base32 or otpauth:// link); items with one show a "2FA auto"
  badge. `hermes vault add` asks for the same optional key.
  browser_vault_fill's result now says what to do next ("if the site asks
  for a verification code, call browser_vault_enter_code with this handle").
  Six locales.

Verified live (real model, local 2FA site that checks the TOTP; CLI PTY):
  A. login saved with authenticator key → signed in through 2FA, zero
     prompts, code/password absent from the transcript
  B. login without key → code panel → user types code → signed in
  C. panel dismissed → agent stops and explains, never asks in chat
Unit: RFC 6238 vectors, seed normalisation, mint-without-asking, per-digit
spread, decline, no-code-field; Desktop card test (owner routing, trim, Skip).
2026-09-10 11:48:01 -07:00

126 lines
5.4 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")
def resolve_otp(self, handle: str) -> Optional[str]:
# `bw get totp <id>` mints the current code from the item's TOTP seed; "No TOTP available" otherwise.
try:
code = self._run("get", "totp", handle[len(self.prefix):]).strip()
except Exception:
return None
return code if code.isdigit() else None