Files
hermes-agent/agent/vault_backends/base.py
2026-10-10 15:20:13 +02:00

140 lines
5.6 KiB
Python

"""Login-backend contract + registry for the browser credential vault.
A ``LoginBackend`` lists login metadata (never secrets) and resolves ONE
password at fill time. External managers (1Password, Bitwarden) additionally
need a per-session unlock; ``resolve_password`` raises ``UnlockRequired``
while locked so the tool can ask the surface to prompt. Handles are
namespaced by ``prefix`` so ``backend_for_handle`` needs no lookup table.
"""
from __future__ import annotations
import subprocess
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Dict, List, Optional, Sequence
from agent.vault_store import VaultItemMeta
class UnlockRequired(Exception):
"""The backend is locked for this session; the surface must prompt for the master password."""
def __init__(self, backend: LoginBackend):
super().__init__(f"{backend.display_name} is locked")
self.backend = backend
class LoginBackend(ABC):
name: str # config key: local | onepassword | bitwarden
display_name: str # user-facing
prefix: str # handle prefix ("vault_", "op:", "bw:")
needs_unlock: bool = False
def owns(self, handle: str) -> bool:
return handle.startswith(self.prefix)
def is_unlocked(self) -> bool:
return True
@abstractmethod
def list_items(self) -> list[VaultItemMeta]:
"""Metadata only. Locked external backends return [] (the agent sees a lock hint instead)."""
@abstractmethod
def get_meta(self, handle: str) -> Optional[VaultItemMeta]: ...
@abstractmethod
def resolve_password(self, handle: str) -> str:
"""Server-side only; raises ``UnlockRequired`` when locked."""
def resolve_otp(self, handle: str) -> Optional[str]:
"""Current one-time code for a login that stores a TOTP seed, else None (the user is asked).
Server-side only, like resolve_password."""
return None
def resolve_secret(self, handle: str) -> dict[str, str]:
"""Full payload of a payment/address item (server-side only). External managers list only
logins, so the base returns the password-only shape."""
return {"password": self.resolve_password(handle)}
def run_with_stdin_secret(argv: Sequence[str], *, env: dict[str, str], secret: str, timeout: float,
label: str) -> subprocess.CompletedProcess:
"""Run a manager CLI feeding *secret* on stdin (never argv, never env). Spawn/timeout → RuntimeError."""
try:
return subprocess.run(
list(argv), env=env, input=secret + "\n", capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout, check=False)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"{label} unlock timed out after {timeout:.0f}s") from exc
except OSError as exc:
raise RuntimeError(f"failed to invoke {label}: {exc}") from exc
def run_with_secret_env(argv: Sequence[str], *, env: dict[str, str], secret_env: str, secret: str, timeout: float,
label: str) -> subprocess.CompletedProcess:
"""Run a manager CLI whose non-interactive contract reads the secret from a named env var.
The variable is set on the child's environment only (never argv, never our process)."""
child_env = dict(env)
child_env[secret_env] = secret
try:
return subprocess.run(
list(argv), env=child_env, stdin=subprocess.DEVNULL, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout, check=False)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"{label} unlock timed out after {timeout:.0f}s") from exc
except OSError as exc:
raise RuntimeError(f"failed to invoke {label}: {exc}") from exc
def _cfg() -> dict:
from hermes_cli.config import load_config_readonly
cfg = load_config_readonly().get("vault") or {}
return cfg if isinstance(cfg, dict) else {}
def external_backend_classes():
from agent.vault_backends.bitwarden import BitwardenLoginBackend
from agent.vault_backends.onepassword import OnePasswordLoginBackend
return (OnePasswordLoginBackend, BitwardenLoginBackend)
def is_installed(name: str) -> bool:
"""Is the manager CLI reachable — honouring a configured ``binary_path`` over PATH."""
import shutil
section = _cfg().get(name) or {}
explicit = str(section.get("binary_path") or "") if isinstance(section, dict) else ""
if explicit:
return Path(explicit).is_file()
if name == "onepassword":
from agent.secret_sources.onepassword import find_op
return find_op() is not None
return shutil.which("bw") is not None
def is_enabled(name: str) -> bool:
"""An installed manager is a login source unless the user opted out (``vault.<name>.enabled: false``).
Zero-config on purpose: a user with ``bw``/``op`` on PATH should never have to discover a toggle."""
section = _cfg().get(name) or {}
if isinstance(section, dict) and section.get("enabled") is False:
return False
return is_installed(name)
def enabled_backends() -> list[LoginBackend]:
"""Local first (always on), then every detected external manager the user has not turned off."""
from agent.vault_backends.local import LocalLoginBackend
cfg = _cfg()
out: list[LoginBackend] = [LocalLoginBackend()]
for cls in external_backend_classes():
if is_enabled(cls.name):
section = cfg.get(cls.name) or {}
out.append(cls(section if isinstance(section, dict) else {}))
return out
def backend_for_handle(handle: str) -> Optional[LoginBackend]:
return next((b for b in enabled_backends() if b.owns(handle)), None)