Files
hermes-agent/tests/agent/test_vault_backends.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

171 lines
8.7 KiB
Python

"""Invariants for external password-manager vault backends (1Password / Bitwarden).
Two contracts that must never regress:
1. A locked manager never prompts where nobody can answer (cron/headless) and never leaks a
value: browser_vault_list reports it under ``locked``, browser_vault_fill refuses.
2. The unlock path hands the master password to the manager CLI through its documented
non-interactive channel (bw: ``--passwordenv`` on the CHILD env only — never argv, never our
process env), keeps just the session token in memory scoped to the profile, and a fill then
routes by handle prefix through the real subprocess path. Locking forgets the token.
"""
from __future__ import annotations
import json
import os
import stat
from unittest.mock import patch
import pytest
from agent.vault_backends import unlock as unlock_mod
from agent.vault_backends.bitwarden import BitwardenLoginBackend
# A stand-in `bw` that mimics the three commands the backend uses and the real CLI's password contract
# (bw 2026.x rejects a piped password: "Master password is required"; it reads --passwordenv <VAR>).
# It records argv + stdin + the named env var so the test can prove where the master password travelled.
# (No env passthrough: the backend's allowlisted child env is part of what is under test.)
_FAKE_BW = r'''#!/usr/bin/env python3
import json, os, sys
log = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "bw.log"), "a")
argv = sys.argv[1:]
stdin = sys.stdin.read() if not sys.stdin.isatty() else ""
pw_env = argv[argv.index("--passwordenv") + 1] if "--passwordenv" in argv else None
log.write(json.dumps({"argv": argv, "stdin": stdin, "BW_SESSION": os.environ.get("BW_SESSION"),
"pw": os.environ.get(pw_env) if pw_env else None}) + "\n")
if argv[:2] == ["unlock", "--raw"]:
if pw_env is None:
sys.stderr.write("Master password is required. Try again in interactive mode or provide a password file or environment variable.\n"); sys.exit(1)
if os.environ.get(pw_env) != "correct horse":
sys.stderr.write("Invalid master password.\n"); sys.exit(1)
print("SESSION-TOKEN-123"); sys.exit(0)
if os.environ.get("BW_SESSION") != "SESSION-TOKEN-123":
sys.stderr.write("Vault is locked.\n"); sys.exit(1)
if argv[:2] == ["list", "items"]:
print(json.dumps([{"id": "abc", "type": 1, "name": "Example", "creationDate": "2026-01-01T00:00:00Z",
"login": {"username": "jane@example.com", "uris": [{"uri": "https://example.com/login"}]}},
{"id": "note", "type": 2, "name": "Secure note"}])); sys.exit(0)
if argv[:2] == ["get", "password"]:
print("plain sentence nobody would flag 7"); sys.exit(0)
sys.exit(2)
'''
pytestmark = pytest.mark.skipif(os.name == "nt", reason="fake bw is a shebang script; the backend under test is host-agnostic")
@pytest.fixture
def fake_bw(tmp_path, monkeypatch):
exe = tmp_path / "bw"
exe.write_text(_FAKE_BW, encoding="utf-8")
exe.chmod(exe.stat().st_mode | stat.S_IXUSR)
log = tmp_path / "bw.log" # the backend runs bw with an allowlisted env, so the fake logs beside itself
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
unlock_mod.lock()
yield exe, log
unlock_mod.lock()
def _enabled(exe):
"""The tool late-imports ``enabled_backends`` from the package facade; ``backend_for_handle`` reads
the sibling's own binding — patch both so the fake is the only backend anywhere."""
backend = BitwardenLoginBackend({"enabled": True, "binary_path": str(exe)})
return patch("agent.vault_backends.base.enabled_backends", return_value=[backend]), backend
def test_locked_manager_is_reported_not_prompted_when_headless(fake_bw, monkeypatch):
exe, log = fake_bw
from tools.browser_vault_tool import browser_vault_fill, browser_vault_list
patcher, backend = _enabled(exe)
monkeypatch.setenv("HERMES_CRON_SESSION", "1") # headless: nobody can answer a prompt
unlock_mod.set_unlock_prompt_callback(lambda *_: "correct horse") # even a wired prompt must not fire
try:
with patcher, patch("agent.vault_backends.enabled_backends", return_value=[backend]):
listed = json.loads(browser_vault_list())
assert listed["items"] == []
assert listed["locked"] == [{"backend": "bitwarden", "display_name": "Bitwarden",
"unlock": "unavailable_in_this_session"}]
filled = json.loads(browser_vault_fill("bw:abc", task_id="t"))
assert filled["success"] is False and filled["error_type"] == "unlock_unavailable"
finally:
unlock_mod.set_unlock_prompt_callback(None)
assert not log.exists(), "bw must not be invoked at all while locked in a headless session"
assert not unlock_mod.is_unlocked("bitwarden")
def test_unlock_uses_vendor_passwordenv_contract_then_fill_routes_by_prefix(fake_bw):
exe, log = fake_bw
from tools.browser_vault_tool import browser_vault_fill, browser_vault_list
patcher, backend = _enabled(exe)
prompts = []
def prompt(name, display):
prompts.append((name, display))
return "correct horse"
unlock_mod.set_unlock_prompt_callback(prompt)
try:
with patcher, patch("agent.vault_backends.enabled_backends", return_value=[backend]), \
patch("tools.browser_vault_tool._current_page_origin", return_value="https://example.com"), \
patch("tools.browser_vault_tool._eval_js", return_value={"success": True, "result": json.dumps([
{"tag": "input", "type": "password", "name": "password", "id": "pw", "autocomplete": "current-password",
"visible": True}])}), \
patch("tools.browser_vault_tool._eval_js_secret", return_value={"success": True, "result": json.dumps(
{"filled": 1})}) as secret_eval:
out = json.loads(browser_vault_fill("bw:abc", task_id="t"))
out.pop("next")
assert out == {"success": True, "filled_fields": 1, "backend": "bitwarden", "kind": "login",
"origin": "https://example.com"}
assert prompts == [("bitwarden", "Bitwarden")]
# Now unlocked: listing exposes metadata only, never the password.
listed = json.loads(browser_vault_list())
assert listed["items"][0]["handle"] == "bw:abc"
assert listed["items"][0]["identifier"] == "jane@example.com"
assert "plain sentence nobody would flag 7" not in json.dumps(listed)
# The password reached the fill script, and only there.
assert "plain sentence nobody would flag 7" in secret_eval.call_args.args[1]
finally:
unlock_mod.set_unlock_prompt_callback(None)
calls = [json.loads(line) for line in log.read_text(encoding="utf-8").splitlines()]
unlock_calls = [c for c in calls if c["argv"][:2] == ["unlock", "--raw"]]
assert len(unlock_calls) == 1 and unlock_calls[0]["pw"] == "correct horse" and unlock_calls[0]["stdin"] == ""
assert all("correct horse" not in " ".join(c["argv"]) for c in calls), "master password must never be argv"
assert all(c["BW_SESSION"] == "SESSION-TOKEN-123" for c in calls if c["argv"][0] != "unlock")
assert "HERMES_BW_MASTER" not in os.environ, "master password env var is child-only"
# Tokens are profile-scoped: another HERMES_HOME sees the manager locked and cannot lock ours.
other = str(exe.parent / "other-profile")
with patch.dict(os.environ, {"HERMES_HOME": other}):
assert not backend.is_unlocked()
unlock_mod.lock("bitwarden")
assert backend.is_unlocked()
unlock_mod.lock("bitwarden")
assert not backend.is_unlocked()
assert os.environ.get("BW_SESSION") is None, "session token must never touch the process env"
def test_lock_during_unlock_wins_and_only_the_owning_session_release_drops_a_token(fake_bw, monkeypatch):
"""A Lock acknowledged while `bw unlock` is still running must not be undone when the child returns;
a session teardown releases only the tokens that session unlocked."""
exe, _log = fake_bw
patcher, backend = _enabled(exe)
with patcher:
# Lock races the in-flight unlock: the generation moved, so the late token is discarded.
gen = unlock_mod.begin_unlock("bitwarden")
unlock_mod.lock("bitwarden")
assert unlock_mod.store_session_token("bitwarden", "LATE-TOKEN", gen) is False
assert not backend.is_unlocked()
unlock_mod.set_current_session_id("sess-A")
backend.unlock("correct horse")
assert backend.is_unlocked()
unlock_mod.release_session("sess-B") # an unrelated sibling session ends
assert backend.is_unlocked()
unlock_mod.release_session("sess-A")
assert not backend.is_unlocked()
unlock_mod.set_current_session_id(None)