Files
hermes-agent/agent/vault_backends/onepassword.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

141 lines
6.5 KiB
Python

"""1Password Login items as a vault backend (``op`` CLI).
Unlock: ``op signin --raw`` with the master password on stdin (desktop-app
integration or account-level auth) mints an ``OP_SESSION_<account>`` token.
A configured service-account token skips the prompt entirely (headless).
List: ``op item list --categories Login --format json`` → title, urls,
username. Resolve: ``op item get <id> --fields label=password --reveal``.
"""
from __future__ import annotations
import json
import logging
import os
import subprocess
from pathlib import Path
from typing import Dict, List, Optional
from agent.secret_sources.base import run_cli
from agent.secret_sources.onepassword import _OP_ENV_ALLOWLIST, _scrub, find_op
from agent.vault_backends.base import LoginBackend, UnlockRequired, run_with_stdin_secret
from agent.vault_backends import unlock as _unlock
from agent.vault_store import VaultItemMeta, normalize_origin
logger = logging.getLogger(__name__)
_TIMEOUT = 30.0
class OnePasswordLoginBackend(LoginBackend):
name = "onepassword"
display_name = "1Password"
prefix = "op:"
needs_unlock = True
def __init__(self, cfg: Optional[Dict] = None):
self.cfg = cfg or {}
from agent.secret_scope import get_secret
env_name = str(self.cfg.get("service_account_token_env") or "OP_SERVICE_ACCOUNT_TOKEN")
self._service_token = get_secret(env_name, "") or ""
# ── auth ────────────────────────────────────────────────────────────────
def _op(self) -> Path:
op = find_op(str(self.cfg.get("binary_path") or ""))
if op is None:
raise RuntimeError("1Password CLI (op) not found — install it or set vault.onepassword.binary_path")
return op
def _env(self, session_token: Optional[str]) -> Dict[str, str]:
from agent.secret_scope import get_secret
env = {k: os.environ[k] for k in _OP_ENV_ALLOWLIST if k in os.environ and not k.startswith("OP_CONNECT_")}
# Connect credentials outrank OP_SERVICE_ACCOUNT_TOKEN inside op, so they must come from the
# profile's own secret scope like the service token does — never from the launch environment.
for k in ("OP_CONNECT_HOST", "OP_CONNECT_TOKEN"):
if v := get_secret(k, ""):
env[k] = v
env["NO_COLOR"] = "1"
account = str(self.cfg.get("account") or "")
if account:
env["OP_ACCOUNT"] = account
if self._service_token:
env["OP_SERVICE_ACCOUNT_TOKEN"] = self._service_token
elif session_token:
# op signin --raw prints the bare token; the env var name carries the account shorthand,
# which op also accepts as plain OP_SESSION for the default account.
env[f"OP_SESSION_{account}" if account else "OP_SESSION"] = session_token
return env
def is_unlocked(self) -> bool:
return bool(self._service_token) or _unlock.is_unlocked(self.name)
def unlock(self, master_password: str) -> None:
"""Mint a session token from the master password (consumed on stdin, never argv)."""
generation = _unlock.begin_unlock(self.name)
cmd = [str(self._op()), "signin", "--raw"]
if account := str(self.cfg.get("account") or ""):
cmd += ["--account", account]
proc = run_with_stdin_secret(cmd, env=self._env(None), secret=master_password, timeout=_TIMEOUT, label="op")
token = (proc.stdout or "").strip()
if proc.returncode != 0 or not token:
raise RuntimeError(f"1Password unlock failed: {_scrub(proc.stderr or '')[:200] or 'no session token'}")
if not _unlock.store_session_token(self.name, token, generation):
raise RuntimeError("1Password was locked while unlocking; try again")
def _run(self, *args: str) -> str:
token = None if self._service_token else _unlock.get_session_token(self.name)
if not self._service_token and not token:
raise UnlockRequired(self)
proc = run_cli([str(self._op()), *args], env=self._env(token), timeout=_TIMEOUT, label="op",
timeout_message="op timed out", stdin=subprocess.DEVNULL)
if proc.returncode != 0:
err = _scrub(proc.stderr or "")
if "session" in err.lower() or "sign in" in err.lower() or "not signed in" in err.lower():
_unlock.lock(self.name)
raise UnlockRequired(self)
raise RuntimeError(f"op failed: {err[:200]}")
return proc.stdout or ""
# ── backend contract ───────────────────────────────────────────────────
def list_items(self) -> List[VaultItemMeta]:
if not self.is_unlocked():
return []
raw = json.loads(self._run("item", "list", "--categories", "Login", "--format", "json") or "[]")
out: List[VaultItemMeta] = []
for item in raw if isinstance(raw, list) else []:
urls = [str(u["href"]) for u in item.get("urls") or [] if isinstance(u, dict) and u.get("href")]
origin = _first_origin(urls)
if not origin:
continue
username = str(item.get("additional_information") or "").strip() or None
out.append(VaultItemMeta(
id=f"{self.prefix}{item.get('id')}", kind="login", label=str(item.get("title") or origin),
origin=origin, created_at=str(item.get("created_at") 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:
item_id = handle[len(self.prefix):]
return self._run("item", "get", item_id, "--fields", "label=password", "--reveal").rstrip("\r\n")
def resolve_otp(self, handle: str) -> Optional[str]:
# `--otp` mints the current TOTP from the item's one-time-password field; items without one error out.
try:
code = self._run("item", "get", handle[len(self.prefix):], "--otp").strip()
except Exception:
return None
return code if code.isdigit() else None
def _first_origin(urls: List[str]) -> Optional[str]:
for u in urls:
try:
return normalize_origin(u)
except Exception:
continue
return None