Files
hermes-agent/tools/thread_context.py
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

75 lines
3.3 KiB
Python

"""Propagate agent-turn context into worker threads that dispatch Hermes tools.
A bare ``threading.Thread`` / ``ThreadPoolExecutor`` worker starts with an empty
``contextvars.Context`` and no thread-local approval/sudo callbacks, so tool dispatch inside it
silently loses the approval ContextVars (gateway sessions then auto-approve dangerous commands)
and the CLI callbacks (``prompt_dangerous_approval`` cannot reach the user, GHSA-qg5c-hvr5-hjgr).
Call :func:`propagate_context_to_thread` **on the parent thread** (it snapshots at call time) and
use the result as the worker target; callbacks are installed for the worker's lifetime and
always cleared on exit.
"""
from __future__ import annotations
import contextvars
import logging
from typing import Callable
logger = logging.getLogger(__name__)
def _callback_api():
"""(getter, setter) pairs for every thread-local prompt callback a tool may need mid-dispatch
(lazy: terminal_tool imports tools.approval at load, so a top-level import risks a cycle).
Add a new per-thread prompt here — a callback missing from this table is silently absent on
every parallel/timeout worker, so the tool believes nobody can answer."""
from agent.vault_backends import unlock as vault_unlock
from tools import terminal_tool as tt
return ((tt._get_approval_callback, tt.set_approval_callback),
(tt._get_sudo_password_callback, tt.set_sudo_password_callback),
(vault_unlock.get_unlock_prompt_callback, vault_unlock.set_unlock_prompt_callback),
(vault_unlock.get_save_login_prompt_callback, vault_unlock.set_save_login_prompt_callback),
(vault_unlock.get_code_prompt_callback, vault_unlock.set_code_prompt_callback))
def propagate_context_to_thread(target: Callable) -> Callable:
"""Wrap *target* to run with the *current* thread's ContextVars and per-thread prompt callbacks
(approval, sudo, password-manager unlock).
Fail-closed: if callback installation raises they stay ``None`` — dangerous commands are then
denied by ``prompt_dangerous_approval`` and the gateway approval queue blocks.
"""
ctx = contextvars.copy_context()
# (setter, parent callback) pairs; None when the callback API could not be captured.
installs = None
try:
installs = tuple((setter, getter()) for getter, setter in _callback_api())
except Exception:
logger.debug("Could not capture parent approval/sudo callbacks", exc_info=True)
def _runner(*args, **kwargs):
def _inner():
if installs is None:
return target(*args, **kwargs)
try:
for setter, cb in installs:
if cb is not None:
setter(cb)
except Exception:
logger.debug("Failed to install propagated approval/sudo callbacks; "
"dangerous-command approval will fail closed", exc_info=True)
try:
return target(*args, **kwargs)
finally:
try:
for setter, _cb in installs:
setter(None)
except Exception:
logger.debug("Failed to clear propagated approval/sudo callbacks",
exc_info=True)
return ctx.run(_inner)
return _runner