From cde0ad0edd20f87b8eca0abe27feafd912073ded Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:03:17 -0700 Subject: [PATCH] feat: agent signs into sites from an encrypted local vault (CLI, browser fill, Desktop Settings) Consolidated re-apply of #96988 onto current main. Ported from Merit-Systems/OpenInstinct (MIT) opaque-handle autofill design: the model sees vault handles + login metadata, the password is resolved and filled server-side over the supervised CDP socket, and filled values are scrubbed from every browser tool result by an unconditional redaction registry. Rebase adaptations to the Sep-2026 facade/sibling layout: - toolsets: one _HERMES_CORE_TOOLS entry (the browser toolset derives from it) - hermes_cli/main.py: vault parser registered via the subcommand owner table - file_safety: vault/ joins the _READ_DENIED_DIRS credential-dir table - redact: registry scrub runs before the redact_secrets early-return - browser_vault_tool: _run_browser_command now lives in browser_tool_session --- agent/file_safety.py | 4 + agent/redact.py | 47 +- agent/vault_login_classifier.py | 209 +++++++ agent/vault_store.py | 320 ++++++++++ apps/desktop/src/app/settings/index.tsx | 26 +- apps/desktop/src/app/settings/types.ts | 1 + .../src/app/settings/vault-settings.test.tsx | 139 +++++ .../src/app/settings/vault-settings.tsx | 563 ++++++++++++++++++ apps/desktop/src/i18n/ar.ts | 50 +- apps/desktop/src/i18n/en.ts | 50 +- apps/desktop/src/i18n/ja.ts | 50 +- apps/desktop/src/i18n/types.ts | 46 ++ apps/desktop/src/i18n/zh-hant.ts | 48 +- apps/desktop/src/i18n/zh.ts | 48 +- apps/desktop/src/lib/icons.ts | 2 + hermes_cli/backup.py | 5 +- hermes_cli/main.py | 3 + hermes_cli/subcommands/vault.py | 21 + hermes_cli/vault.py | 174 ++++++ tests/test_browser_vault.py | 515 ++++++++++++++++ tests/tui_gateway/test_vault_methods.py | 114 ++++ tools/browser_vault_tool.py | 392 ++++++++++++ toolsets.py | 1 + tui_gateway/methods_vault.py | 90 +++ tui_gateway/server.py | 5 +- .../user-guide/features/credential-vault.md | 120 ++++ website/sidebars.ts | 1 + 27 files changed, 3033 insertions(+), 11 deletions(-) create mode 100644 agent/vault_login_classifier.py create mode 100644 agent/vault_store.py create mode 100644 apps/desktop/src/app/settings/vault-settings.test.tsx create mode 100644 apps/desktop/src/app/settings/vault-settings.tsx create mode 100644 hermes_cli/subcommands/vault.py create mode 100644 hermes_cli/vault.py create mode 100644 tests/test_browser_vault.py create mode 100644 tests/tui_gateway/test_vault_methods.py create mode 100644 tools/browser_vault_tool.py create mode 100644 tui_gateway/methods_vault.py create mode 100644 website/docs/user-guide/features/credential-vault.md diff --git a/agent/file_safety.py b/agent/file_safety.py index 937a73cc09..1ee92367ad 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -211,6 +211,10 @@ _READ_DENIED_DIRS = ( ("browser-profile", "is the Hermes real-profile browser snapshot directory (copied cookies/logins) and cannot be read directly.", "is inside the Hermes real-profile browser snapshot (copied cookies/logins) and cannot be read directly."), + # vault.key + vault.json.enc sit side by side; key + ciphertext = plaintext, so the whole dir is one credential. + ("vault", + "is the Hermes credential vault directory and cannot be read directly (secrets are filled server-side by browser_vault_fill).", + "is inside the Hermes credential vault (encrypted secrets + local key) and cannot be read directly (browser_vault_fill resolves them server-side)."), ) diff --git a/agent/redact.py b/agent/redact.py index 34a8df803f..9c38cd571d 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -17,6 +17,47 @@ from agent.file_safety import _BLOCKED_PROJECT_ENV_BASENAMES as _ENV_FILE_BASENA logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Session-scoped vault-value redaction registry +# --------------------------------------------------------------------------- +# Exact secret values that transited a server-side vault fill this process +# lifetime (browser_vault_fill). Generic credential-shaped regexes cannot +# catch an arbitrary user password, so the fill path registers the exact +# bytes here and every browser_* tool result (including browser_cdp +# Runtime.evaluate passthrough) is scrubbed against them before it can +# reach the model. Values are held in memory only — never persisted, +# never logged, cleared with the process. +_VAULT_REDACTION_VALUES: set = set() +_VAULT_REDACTION_LOCK = threading.Lock() +_VAULT_REDACTION_MIN_LEN = 4 # avoid pathological scrubs on 1-3 char values + + +def register_vault_redaction_value(value) -> None: + """Register an exact vault secret value for model-facing redaction. + + Called by the vault fill path for every secret value it injects into a + page, BEFORE the injection happens, so no later browser tool result can + echo the value back into model context. + """ + if not isinstance(value, str): + return + if len(value) < _VAULT_REDACTION_MIN_LEN: + return + with _VAULT_REDACTION_LOCK: + _VAULT_REDACTION_VALUES.add(value) + + +def redact_registered_vault_values(text: str) -> str: + """Exact-substring scrub of every registered vault secret value.""" + if not isinstance(text, str) or not text: + return text + with _VAULT_REDACTION_LOCK: + values = list(_VAULT_REDACTION_VALUES) + for value in values: + if value in text: + text = text.replace(value, "«redacted-vault-secret»") + return text + # Sensitive query-string param names (case-insensitive): opaque tokens / OAuth # codes / pre-signed signatures with no vendor prefix. # Ported from nearai/ironclaw#2529 — catches tokens whose values don't match any known vendor prefix regex @@ -583,7 +624,11 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F if text is None: return None text = text if isinstance(text, str) else str(text) - if not text or not (force or _REDACT_ENABLED): + if not text: + return text + # Vault secrets are a hard model-egress boundary: scrubbed regardless of the redact_secrets preference. + text = redact_registered_vault_values(text) + if not (force or _REDACT_ENABLED): return text code_file = code_file or file_read diff --git a/agent/vault_login_classifier.py b/agent/vault_login_classifier.py new file mode 100644 index 0000000000..25952d3d02 --- /dev/null +++ b/agent/vault_login_classifier.py @@ -0,0 +1,209 @@ +"""Login-form control classifier for vault autofill. + +Python port (~170 LOC) of Merit-Systems/OpenInstinct's +``lib/manager/server/kernel-login-autofill.ts`` (MIT). Classifies visible +input controls on a page into login-autofill tokens. The vault fill path +uses the classification to select the single best current-password control +(the identifier is agent-visible metadata and is typed by the agent +itself via normal input tools). + +Scoring: +- exact autocomplete-token match ................ 100 +- type=password (not new/confirm/create/repeat) .. 90 +- type=email / type=tel .......................... 85 +- label/name regex heuristics .................. 70-75 +Hard exclusions: autocomplete ``new-password`` / ``one-time-code``, and +label/name text matching ``(new|confirm|create|repeat)\\s*password``. +""" + +from __future__ import annotations + +import json +import re +import unicodedata +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +LOGIN_AUTOFILL_TOKENS = ("username", "email", "tel", "current-password") + +_EXCLUDED_AUTOCOMPLETE = {"new-password", "one-time-code"} + +_RE_EXCLUDED_PASSWORD = re.compile(r"\b(?:new|confirm|create|repeat)\s*password\b") +_RE_EMAIL = re.compile(r"\b(?:e[\s-]?mail|email address)\b") +_RE_TEL = re.compile(r"\b(?:phone|telephone|mobile)\b") +_RE_USERNAME = re.compile( + r"\b(?:user\s*name|username|login|account|member|membership|mileageplus)\b" +) + + +def _normalize_text(value: str) -> str: + value = unicodedata.normalize("NFKD", value).lower() + return re.sub(r"[^a-z0-9]+", " ", value).strip() + + +@dataclass(frozen=True) +class LoginControl: + """Descriptor of a visible input control, as inspected in the page.""" + + autocomplete: str + form_index: Optional[int] + index: int + label: str + name: str + type: str + + @classmethod + def from_dict(cls, raw: Dict[str, Any]) -> "LoginControl": + form_index = raw.get("formIndex", raw.get("form_index")) + return cls( + autocomplete=str(raw.get("autocomplete") or ""), + form_index=int(form_index) if form_index is not None else None, + index=int(raw.get("index") or 0), + label=str(raw.get("label") or ""), + name=str(raw.get("name") or ""), + type=str(raw.get("type") or ""), + ) + + +@dataclass(frozen=True) +class ClassifiedLoginControl: + control: LoginControl + score: int + token: str + + +def classify_login_control(control: LoginControl) -> Optional[ClassifiedLoginControl]: + """Classify one control, or return None if it is not a login fill target.""" + autocomplete_tokens = [ + t for t in control.autocomplete.lower().split() if t + ] + if any(t in _EXCLUDED_AUTOCOMPLETE for t in autocomplete_tokens): + return None + + for token in LOGIN_AUTOFILL_TOKENS: + if token in autocomplete_tokens: + return ClassifiedLoginControl(control, 100, token) + + searchable = _normalize_text( + " ".join(part for part in (control.name, control.label) if part) + ) + if _RE_EXCLUDED_PASSWORD.search(searchable): + return None + if control.type == "password": + return ClassifiedLoginControl(control, 90, "current-password") + if control.type == "email": + return ClassifiedLoginControl(control, 85, "email") + if control.type == "tel": + return ClassifiedLoginControl(control, 85, "tel") + if _RE_EMAIL.search(searchable): + return ClassifiedLoginControl(control, 75, "email") + if _RE_TEL.search(searchable): + return ClassifiedLoginControl(control, 75, "tel") + if _RE_USERNAME.search(searchable): + return ClassifiedLoginControl(control, 70, "username") + return None + + +def select_password_fill( + classified: List[ClassifiedLoginControl], + password: str, +) -> List[Dict[str, Any]]: + """Select the single best current-password control to fill. + + The vault fill path is password-only: the identifier is agent-visible + metadata and is typed by the agent via normal input tools. This picks + the highest-scoring ``current-password`` control (ties broken by DOM + order) and returns ``[{"index": int, "token": "current-password", + "value": password}]`` or ``[]`` when no password field exists. + """ + passwords = [c for c in classified if c.token == "current-password"] + if not passwords or not password: + return [] + best_password = sorted( + passwords, key=lambda c: (-c.score, c.control.index) + )[0] + return [ + { + "index": best_password.control.index, + "token": "current-password", + "value": password, + } + ] + + +# JS expression evaluated in the page to inspect candidate input controls. +# Ported from OpenInstinct's nativeLoginControlInspectionExpression. +LOGIN_CONTROL_INSPECTION_JS = """(() => { + const elements = Array.from(document.querySelectorAll("input")); + const forms = Array.from(document.forms); + const out = elements.flatMap((element, index) => { + if (element.disabled || element.readOnly) return []; + if (["hidden", "submit", "button", "reset", "file", "image", "checkbox", "radio"].includes(element.type)) return []; + const style = getComputedStyle(element); + if (style.display === "none" || style.visibility === "hidden" || element.getClientRects().length === 0) return []; + const labels = element.labels ? Array.from(element.labels, (l) => l.textContent || "") : []; + const ariaText = (element.getAttribute("aria-labelledby") || "") + .split(/\\s+/).filter(Boolean) + .map((id) => { const n = document.getElementById(id); return n ? (n.textContent || "") : ""; }) + .join(" "); + const resolvedFormIndex = element.form ? forms.indexOf(element.form) : -1; + return [{ + autocomplete: element.autocomplete || "", + formIndex: resolvedFormIndex >= 0 ? resolvedFormIndex : null, + index, + label: [ + ...labels, + element.getAttribute("aria-label") || "", + ariaText, + element.getAttribute("placeholder") || "", + element.getAttribute("title") || "", + ].join(" "), + name: [element.name, element.id].join(" "), + type: element.type || "", + }]; + }); + return JSON.stringify(out); +})()""" + + +def build_fill_js(fills: List[Dict[str, Any]], expected_origin: str) -> str: + """Build a JS expression that fills the selected inputs and reports + only a count. The returned expression never echoes the values back. + + ``expected_origin`` is asserted against ``window.location.origin`` + synchronously inside the SAME evaluated script, immediately before any + write. If the page navigated between inspection and fill (TOCTOU), the + script writes nothing and returns + ``{"refused": "origin_changed", "found": }`` — proof + scope equals mutation scope (#88706). No marker attribute is set on + filled controls: filled fields must not be deterministically + addressable by later model-driven DOM reads. + """ + payload = json.dumps( + [{"index": f["index"], "value": f["value"]} for f in fills] + ) + expected = json.dumps(expected_origin) + return ( + "(() => {\n" + f" const expectedOrigin = {expected};\n" + " if (window.location.origin !== expectedOrigin) {\n" + " return JSON.stringify({ refused: \"origin_changed\", found: window.location.origin });\n" + " }\n" + f" const fills = {payload};\n" + " const elements = Array.from(document.querySelectorAll(\"input\"));\n" + " let filled = 0;\n" + " for (const f of fills) {\n" + " const el = elements[f.index];\n" + " if (!el) continue;\n" + " try {\n" + " el.focus();\n" + " const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, \"value\");\n" + " if (setter && setter.set) { setter.set.call(el, f.value); } else { el.value = f.value; }\n" + " el.dispatchEvent(new InputEvent(\"input\", { bubbles: true, inputType: \"insertText\" }));\n" + " el.dispatchEvent(new Event(\"change\", { bubbles: true }));\n" + " if (el.value.length > 0) filled += 1;\n" + " } catch (e) { /* skip */ }\n" + " }\n" + " return JSON.stringify({ filled });\n" + "})()" + ) diff --git a/agent/vault_store.py b/agent/vault_store.py new file mode 100644 index 0000000000..025e4df77d --- /dev/null +++ b/agent/vault_store.py @@ -0,0 +1,320 @@ +"""Local encrypted vault for browser autofill secrets. + +Profile-scoped, model-blind credential store. Metadata (kind, label, origin, +timestamps) lives alongside an encrypted secret payload; the payload is +encrypted at rest with a locally generated Fernet key. The model only ever +sees opaque handles + metadata — secret values are resolved server-side by +the browser fill path and never enter tool results, logs, or the session DB. + +Design notes: +- Follows the repo's "default frictionless, 0600 files OK" policy: the key + file and vault file are created 0600 under ``/vault/``. +- Ported design (opaque-handle vault fill) from Merit-Systems/OpenInstinct + (MIT): lib/manager/server/secret-store.ts + vault services. +- Phase 1 supports three item kinds (``login``, ``payment``, ``address``) + in the store; browser fill support is login-only. +""" + +from __future__ import annotations + +import json +import os +import re +import threading +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional +from urllib.parse import urlsplit + +from hermes_constants import get_hermes_home + +VAULT_KINDS = ("login", "payment", "address") + +LOGIN_IDENTIFIER_TYPES = ("email", "phone", "username") + +_DEFAULT_PORTS = {"http": 80, "https": 443} + +_LOCK = threading.Lock() + + +class VaultError(Exception): + """Vault failure that is safe to surface (never contains secret values).""" + + +def normalize_origin(url_or_origin: str) -> str: + """Normalize a URL or origin to ``scheme://host[:port]``. + + Default ports (80 for http, 443 for https) are stripped so that + ``https://example.com`` and ``https://example.com:443`` compare equal. + Raises :class:`VaultError` for values without a scheme + host. + """ + value = (url_or_origin or "").strip() + if not value: + raise VaultError("origin is required") + if "://" not in value: + raise VaultError(f"origin must include a scheme (got {value!r})") + parts = urlsplit(value) + scheme = (parts.scheme or "").lower() + host = (parts.hostname or "").lower() + if not scheme or not host: + raise VaultError(f"could not parse origin from {value!r}") + try: + port = parts.port + except ValueError as exc: + raise VaultError(f"invalid port in origin {value!r}") from exc + if port is None or port == _DEFAULT_PORTS.get(scheme): + return f"{scheme}://{host}" + return f"{scheme}://{host}:{port}" + + +@dataclass(frozen=True) +class VaultItemMeta: + """Metadata-only view of a vault item. Never contains secret values. + + For ``kind='login'`` the identifier (email/username/phone) is metadata, + not a secret: the agent may see it and type it itself. Only the password + is vault-secret. + """ + + id: str + kind: str + label: str + origin: Optional[str] + created_at: str + identifier_type: Optional[str] = None + identifier: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + out = { + "id": self.id, + "kind": self.kind, + "label": self.label, + "origin": self.origin, + "created_at": self.created_at, + } + if self.identifier is not None: + out["identifier"] = self.identifier + out["identifier_type"] = self.identifier_type + return out + + +class VaultStore: + """Encrypted, profile-scoped vault under ``/vault/``.""" + + def __init__(self, base_dir: Optional[Path] = None): + self._base = Path(base_dir) if base_dir is not None else ( + Path(get_hermes_home()) / "vault" + ) + self._vault_path = self._base / "vault.json.enc" + self._key_path = self._base / "vault.key" + + # -- key / crypto ------------------------------------------------------ + + def _ensure_dir(self) -> None: + self._base.mkdir(mode=0o700, parents=True, exist_ok=True) + # Route through the canonical securer (honors managed/NixOS + # group-share mode and HERMES_UID/GID ownership) rather than a + # bespoke chmod — same requirement as the browser-profile snapshot + # dir (f1d05c review). + try: + from hermes_cli.config import _secure_dir + + _secure_dir(self._base) + except Exception: + try: + os.chmod(self._base, 0o700) + except OSError: + pass + + def _fernet(self): + from cryptography.fernet import Fernet + + self._ensure_dir() + if not self._key_path.exists(): + key = Fernet.generate_key() + fd = os.open( + self._key_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600 + ) + try: + os.write(fd, key) + finally: + os.close(fd) + else: + key = self._key_path.read_bytes().strip() + try: + os.chmod(self._key_path, 0o600) + except OSError: + pass + return Fernet(key) + + # -- persistence ------------------------------------------------------- + + def _read_all(self) -> List[Dict[str, Any]]: + if not self._vault_path.exists(): + return [] + blob = self._vault_path.read_bytes() + if not blob: + return [] + from cryptography.fernet import InvalidToken + + try: + raw = self._fernet().decrypt(blob) + except InvalidToken as exc: + raise VaultError( + "vault file could not be decrypted (key mismatch or corruption)" + ) from exc + data = json.loads(raw.decode("utf-8")) + items = data.get("items", []) + return items if isinstance(items, list) else [] + + def _write_all(self, items: List[Dict[str, Any]]) -> None: + self._ensure_dir() + payload = json.dumps({"version": 1, "items": items}).encode("utf-8") + blob = self._fernet().encrypt(payload) + tmp = self._vault_path.with_suffix(".enc.tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, blob) + finally: + os.close(fd) + os.replace(tmp, self._vault_path) + try: + os.chmod(self._vault_path, 0o600) + except OSError: + pass + + # -- public API ---------------------------------------------------------- + + def add_item( + self, + kind: str, + label: str, + secret: Dict[str, Any], + origin: Optional[str] = None, + ) -> VaultItemMeta: + """Add an item. ``secret`` is the sensitive payload (encrypted at rest). + + For ``kind='login'``, ``origin`` is required and the payload must + contain ``identifier_type``, ``identifier`` and ``password``. The + identifier fields are NOT secret — they are moved into item metadata + (the agent may see and type the identifier itself); only + ``password`` stays in the encrypted secret payload. ``payment`` and + ``address`` payloads remain fully secret. + """ + if kind not in VAULT_KINDS: + raise VaultError(f"unknown vault kind {kind!r} (expected one of {VAULT_KINDS})") + label = (label or "").strip() + if not label: + raise VaultError("label is required") + norm_origin: Optional[str] = None + identifier: Optional[str] = None + identifier_type: Optional[str] = None + secret = dict(secret) + if kind == "login": + if not origin: + raise VaultError("origin is required for login items") + norm_origin = normalize_origin(origin) + id_type = secret.pop("identifier_type", None) + if id_type not in LOGIN_IDENTIFIER_TYPES: + raise VaultError( + f"identifier_type must be one of {LOGIN_IDENTIFIER_TYPES}" + ) + identifier = str(secret.pop("identifier", "") or "").strip() + if not identifier or not secret.get("password"): + raise VaultError("login items require identifier and password") + identifier_type = str(id_type) + # Login secret payload is password-only; identifier lives in + # metadata and any stray origin echo is dropped. + secret = {"password": secret["password"]} + elif origin: + norm_origin = normalize_origin(origin) + + item_id = f"vault_{uuid.uuid4().hex[:12]}" + record = { + "id": item_id, + "kind": kind, + "label": label, + "origin": norm_origin, + "created_at": datetime.now(timezone.utc).isoformat(), + "identifier_type": identifier_type, + "identifier": identifier, + "secret": dict(secret), + } + with _LOCK: + items = self._read_all() + items.append(record) + self._write_all(items) + return self._meta(record) + + def list_items(self) -> List[VaultItemMeta]: + """Metadata-only listing. Secret payloads are never included.""" + with _LOCK: + return [self._meta(rec) for rec in self._read_all()] + + def has_items(self) -> bool: + try: + with _LOCK: + return bool(self._read_all()) + except Exception: + return False + + def remove_item(self, item_id: str) -> bool: + with _LOCK: + items = self._read_all() + remaining = [rec for rec in items if rec.get("id") != item_id] + if len(remaining) == len(items): + return False + self._write_all(remaining) + return True + + def get_meta(self, item_id: str) -> Optional[VaultItemMeta]: + with _LOCK: + for rec in self._read_all(): + if rec.get("id") == item_id: + return self._meta(rec) + return None + + def resolve_secret(self, item_id: str) -> Dict[str, Any]: + """Resolve the decrypted secret payload for server-side use ONLY. + + Callers must never place the returned values into tool results, + logs, exceptions, or any string that reaches the session DB. + """ + with _LOCK: + for rec in self._read_all(): + if rec.get("id") == item_id: + return dict(rec.get("secret") or {}) + raise VaultError(f"no vault item with id {item_id!r}") + + @staticmethod + def _meta(rec: Dict[str, Any]) -> VaultItemMeta: + identifier = rec.get("identifier") + return VaultItemMeta( + id=str(rec.get("id", "")), + kind=str(rec.get("kind", "")), + label=str(rec.get("label", "")), + origin=rec.get("origin"), + created_at=str(rec.get("created_at", "")), + identifier_type=rec.get("identifier_type") if identifier else None, + identifier=identifier or None, + ) + + +def get_vault_store() -> VaultStore: + """Default profile-scoped vault store.""" + return VaultStore() + + +def scrub_secret_from_text(text: str, secret: Dict[str, Any]) -> str: + """Defensively strip any secret values from a string (e.g. an exception + message) before it can be surfaced. Case-sensitive exact substring scrub.""" + scrubbed = text + for value in secret.values(): + if isinstance(value, str) and len(value) >= 3 and value in scrubbed: + scrubbed = scrubbed.replace(value, "[REDACTED]") + # Also collapse anything that looks like a leaked password-ish token in + # common key=value echoes. + scrubbed = re.sub(r"(password['\"]?\s*[:=]\s*)\S+", r"\1[REDACTED]", scrubbed) + return scrubbed diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index e5eddbd097..3238e068a8 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -21,6 +21,7 @@ import { RefreshCw, Search, Settings2, + ShieldLock, Upload, Wrench, Zap @@ -53,6 +54,7 @@ import { NotificationsSettings } from './notifications-settings' import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings' import { SessionsSettings } from './sessions-settings' import type { SettingsPageProps, SettingsView as SettingsViewId } from './types' +import { VaultSettings } from './vault-settings' const SETTINGS_VIEWS: readonly SettingsViewId[] = [ ...SECTIONS.map(s => `config:${s.id}` as SettingsViewId), @@ -63,6 +65,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ 'connections', 'keybinds', 'keys', + 'vault', 'notifications', 'billing', 'sessions', @@ -167,16 +170,33 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set const navGroups: OverlayNavGroup[] = useMemo( () => [ - ...SECTIONS.map(s => { + ...SECTIONS.flatMap(s => { const view = `config:${s.id}` as SettingsViewId - return { + const entry = { active: activeView === view, icon: s.icon, id: view, label: t.settings.sections[s.id] ?? s.label, onSelect: () => setActiveView(view) } + + // Credential Vault lives beside the Browser section: it feeds the + // browser's model-blind vault fill, so the two are one mental unit. + if (s.id === 'browser') { + return [ + entry, + { + active: activeView === 'vault', + icon: ShieldLock, + id: 'vault', + label: t.settings.nav.vault, + onSelect: () => setActiveView('vault') + } + ] + } + + return [entry] }), { active: activeView === 'notifications', @@ -410,6 +430,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set ) : activeView === 'billing' ? ( + ) : activeView === 'vault' ? ( + ) : ( ) diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index 18c42af104..b8481bb47b 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -14,6 +14,7 @@ export type SettingsView = | 'notifications' | 'providers' | 'sessions' + | 'vault' | `config:${string}` export type EnvPatch = Partial> diff --git a/apps/desktop/src/app/settings/vault-settings.test.tsx b/apps/desktop/src/app/settings/vault-settings.test.tsx new file mode 100644 index 0000000000..4f5777bb57 --- /dev/null +++ b/apps/desktop/src/app/settings/vault-settings.test.tsx @@ -0,0 +1,139 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { stubResizeObserver } from '@/test/jsdom' + +const { requestGateway } = vi.hoisted(() => ({ + requestGateway: vi.fn() +})) + +vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ + useGatewayRequest: () => ({ requestGateway }) +})) + +import { queryClient } from '@/lib/query-client' +import { $gatewayState } from '@/store/session' + +import { VaultSettings } from './vault-settings' + +stubResizeObserver() + +const renderVault = (route = '/settings?tab=vault') => + render( + + + + + + ) + +const LOGIN_ITEM = { + id: 'vault_abc123', + kind: 'login', + label: 'GitHub work', + origin: 'https://github.com', + created_at: '2026-08-01T12:00:00+00:00', + identifier: 'me@example.com', + identifier_type: 'email' +} + +beforeEach(() => { + requestGateway.mockReset() + queryClient.clear() + $gatewayState.set('open') +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('VaultSettings', () => { + it('shows the empty state when the vault has no items', async () => { + requestGateway.mockResolvedValue({ items: [] }) + renderVault() + + await waitFor(() => expect(screen.getByText('No saved credentials yet')).toBeTruthy()) + expect(requestGateway).toHaveBeenCalledWith('vault.list', {}) + }) + + it('lists items with label, kind badge, identifier, and origin — never passwords', async () => { + requestGateway.mockResolvedValue({ items: [LOGIN_ITEM] }) + renderVault() + + await waitFor(() => expect(screen.getByText('GitHub work')).toBeTruthy()) + expect(screen.getByText('Login')).toBeTruthy() + // Identifier is agent-visible metadata and now shows in the row. + expect(screen.getByText('me@example.com')).toBeTruthy() + expect(screen.getByText('https://github.com')).toBeTruthy() + }) + + it('opens the Add dialog pre-filled from deep-link query params (never secrets)', async () => { + requestGateway.mockResolvedValue({ items: [] }) + renderVault('/settings?tab=vault&kind=login&label=github&origin=https://github.com') + + await waitFor(() => expect(screen.getByLabelText('Label')).toBeTruthy()) + expect((screen.getByLabelText('Label') as HTMLInputElement).value).toBe('github') + expect((screen.getByLabelText('Site origin') as HTMLInputElement).value).toBe('https://github.com') + // The password field always starts empty — a secret can never arrive via link. + expect((screen.getByLabelText('Password') as HTMLInputElement).value).toBe('') + }) + + it('validates the origin before submitting a login item', async () => { + requestGateway.mockResolvedValue({ items: [] }) + renderVault() + + fireEvent.click(await screen.findByRole('button', { name: 'Add credential' })) + fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'x' } }) + fireEvent.change(screen.getByLabelText('Site origin'), { target: { value: 'not-a-url' } }) + fireEvent.change(screen.getByLabelText('Identifier'), { target: { value: 'me@example.com' } }) + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pw' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save to vault' })) + + await waitFor(() => expect(screen.getByText('Enter a valid URL like https://example.com.')).toBeTruthy()) + expect(requestGateway).not.toHaveBeenCalledWith('vault.add', expect.anything()) + }) + + it('submits vault.add and refetches the list on success', async () => { + requestGateway.mockImplementation(async (method: string) => + method === 'vault.list' ? { items: [] } : { id: 'vault_new' } + ) + renderVault() + + fireEvent.click(await screen.findByRole('button', { name: 'Add credential' })) + fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'GitHub work' } }) + fireEvent.change(screen.getByLabelText('Site origin'), { target: { value: 'https://github.com' } }) + fireEvent.change(screen.getByLabelText('Identifier'), { target: { value: 'me@example.com' } }) + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 's3cret' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save to vault' })) + + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith('vault.add', { + kind: 'login', + label: 'GitHub work', + origin: 'https://github.com', + secret: { + identifier_type: 'email', + identifier: 'me@example.com', + password: 's3cret' + } + }) + ) + }) + + it('deletes an item through the confirm dialog', async () => { + requestGateway.mockImplementation(async (method: string) => + method === 'vault.list' ? { items: [LOGIN_ITEM] } : { removed: true } + ) + renderVault() + + await waitFor(() => expect(screen.getByText('GitHub work')).toBeTruthy()) + fireEvent.click(screen.getByRole('button', { name: 'Delete credential' })) + await waitFor(() => expect(screen.getByText('Delete credential?')).toBeTruthy()) + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + + await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('vault.remove', { id: 'vault_abc123' })) + }) +}) diff --git a/apps/desktop/src/app/settings/vault-settings.tsx b/apps/desktop/src/app/settings/vault-settings.tsx new file mode 100644 index 0000000000..3011775811 --- /dev/null +++ b/apps/desktop/src/app/settings/vault-settings.tsx @@ -0,0 +1,563 @@ +import { useStore } from '@nanostores/react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useSearchParams } from 'react-router' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { Button } from '@/components/ui/button' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { EmptyState } from '@/components/ui/empty-state' +import { Field } from '@/components/ui/field' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { Plus, ShieldLock, Trash2 } from '@/lib/icons' +import { notify, notifyError } from '@/store/notifications' +import { $gatewayState } from '@/store/session' + +import { CONTROL_TEXT } from './constants' +import { ListRow, Pill, SectionHeading, SettingsContent } from './primitives' + +const VAULT_QUERY_KEY = ['vault-items'] as const + +export type VaultKind = 'address' | 'login' | 'payment' +const VAULT_KINDS: readonly VaultKind[] = ['login', 'payment', 'address'] +const IDENTIFIER_TYPES = ['email', 'phone', 'username'] as const +type IdentifierType = (typeof IDENTIFIER_TYPES)[number] + +interface VaultItem { + id: string + kind: string + label: string + origin: null | string + created_at: string + identifier?: null | string + identifier_type?: null | string +} + +/** Add-dialog prefill from a deep link (`/settings?tab=vault&kind=…`). NEVER secrets. */ +export interface VaultPrefill { + kind?: string + label?: string + origin?: string +} + +function isVaultKind(value: string | undefined): value is VaultKind { + return !!value && (VAULT_KINDS as readonly string[]).includes(value) +} + +function isValidOrigin(value: string): boolean { + try { + const url = new URL(value) + + return (url.protocol === 'https:' || url.protocol === 'http:') && !!url.hostname + } catch { + return false + } +} + +const EMPTY_FORM = { + kind: 'login' as VaultKind, + label: '', + origin: '', + identifierType: 'email' as IdentifierType, + identifier: '', + password: '', + cardNumber: '', + cardName: '', + expMonth: '', + expYear: '', + cvc: '', + postal: '', + line1: '', + line2: '', + city: '', + state: '', + country: '' +} + +type VaultForm = typeof EMPTY_FORM + +function buildSecret(form: VaultForm): Record { + if (form.kind === 'login') { + // identifier_type/identifier are stored as agent-visible metadata by the + // vault store; only the password stays in the encrypted secret payload. + return { + identifier_type: form.identifierType, + identifier: form.identifier.trim(), + password: form.password + } + } + + if (form.kind === 'payment') { + return { + card_number: form.cardNumber.replace(/\s+/g, ''), + cardholder_name: form.cardName.trim(), + exp_month: form.expMonth.trim(), + exp_year: form.expYear.trim(), + cvc: form.cvc, + billing_postal_code: form.postal.trim() + } + } + + const secret: Record = { + address_line1: form.line1.trim(), + city: form.city.trim(), + postal_code: form.postal.trim(), + country: form.country.trim() + } + + if (form.line2.trim()) { + secret.address_line2 = form.line2.trim() + } + + if (form.state.trim()) { + secret.state = form.state.trim() + } + + return secret +} + +export function VaultSettings() { + const { t } = useI18n() + const v = t.settings.vault + const { requestGateway } = useGatewayRequest() + const gatewayState = useStore($gatewayState) + const queryClient = useQueryClient() + const [searchParams, setSearchParams] = useSearchParams() + + const [addOpen, setAddOpen] = useState(false) + const [form, setForm] = useState(EMPTY_FORM) + const [formError, setFormError] = useState(null) + const [pendingDelete, setPendingDelete] = useState(null) + + const { data, error, isPending } = useQuery({ + enabled: gatewayState === 'open', + queryKey: VAULT_QUERY_KEY, + queryFn: async () => { + const result = await requestGateway<{ items: VaultItem[] }>('vault.list', {}) + + return result.items + } + }) + + useEffect(() => { + if (error) { + notifyError(error, v.loadFailed) + } + }, [error, v.loadFailed]) + + const items = useMemo(() => data ?? [], [data]) + + // Clears the secret fields with the rest of the form — the password/CVC + // never outlive the dialog. + const closeAdd = useCallback(() => { + setAddOpen(false) + setForm(EMPTY_FORM) + setFormError(null) + }, []) + + const openAdd = useCallback((prefill?: VaultPrefill) => { + setForm({ + ...EMPTY_FORM, + kind: isVaultKind(prefill?.kind) ? prefill.kind : 'login', + label: prefill?.label ?? '', + origin: prefill?.origin ?? '' + }) + setFormError(null) + setAddOpen(true) + }, []) + + // Deep link (`hermes://open/settings?tab=vault&kind=login&label=…&origin=…`, + // e.g. relayed by the agent when a login is missing): open the Add dialog + // pre-filled from the query params — metadata only, never a secret — then + // drop the params so a refresh doesn't re-open it. + useEffect(() => { + const kind = searchParams.get('kind') ?? undefined + const label = searchParams.get('label') ?? undefined + const origin = searchParams.get('origin') ?? undefined + + if (!kind && !label && !origin) { + return + } + + openAdd({ kind, label, origin }) + const next = new URLSearchParams(searchParams) + next.delete('kind') + next.delete('label') + next.delete('origin') + setSearchParams(next, { replace: true }) + }, [openAdd, searchParams, setSearchParams]) + + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: VAULT_QUERY_KEY }), + [queryClient] + ) + + const addMutation = useMutation({ + mutationFn: async (payload: { kind: VaultKind; label: string; origin?: string; secret: Record }) => + requestGateway<{ id: string }>('vault.add', payload), + onSuccess: () => { + triggerHaptic('success') + notify({ kind: 'info', message: v.added }) + closeAdd() + void invalidate() + }, + onError: err => { + setFormError(String(err instanceof Error ? err.message : err)) + } + }) + + const submitAdd = useCallback(() => { + setFormError(null) + + if (!form.label.trim()) { + setFormError(v.labelRequired) + + return + } + + const needsOrigin = form.kind === 'login' + const origin = form.origin.trim() + + if (needsOrigin && !isValidOrigin(origin)) { + setFormError(v.originInvalid) + + return + } + + if (!needsOrigin && origin && !isValidOrigin(origin)) { + setFormError(v.originInvalid) + + return + } + + if (form.kind === 'login' && (!form.identifier.trim() || !form.password)) { + setFormError(v.loginFieldsRequired) + + return + } + + addMutation.mutate({ + kind: form.kind, + label: form.label.trim(), + ...(origin ? { origin } : {}), + secret: buildSecret(form) + }) + }, [addMutation, form, v.labelRequired, v.loginFieldsRequired, v.originInvalid]) + + const deleteItem = useCallback( + async (item: VaultItem) => { + await requestGateway<{ removed: boolean }>('vault.remove', { id: item.id }) + triggerHaptic('success') + void invalidate() + }, + [invalidate, requestGateway] + ) + + const kindLabel = useCallback((kind: string) => v.kinds[kind as VaultKind] ?? kind, [v.kinds]) + + const formatCreated = useCallback((iso: string) => { + const parsed = new Date(iso) + + return Number.isNaN(parsed.getTime()) ? iso : parsed.toLocaleDateString() + }, []) + + return ( + + openAdd()} size="sm" type="button" variant="outline"> + + {v.add} + + } + icon={ShieldLock} + meta={items.length > 0 ? v.count(items.length) : undefined} + title={v.title} + /> +

+ {v.blurb} +

+ + {!isPending && items.length === 0 && } + + {items.map(item => ( + setPendingDelete(item)} + size="icon-sm" + type="button" + variant="ghost" + > + + + } + description={ + + {item.identifier && {v.identifierShown(item.identifier)}} + {item.origin && {item.origin}} + {v.createdOn(formatCreated(item.created_at))} + + } + key={item.id} + title={ + + {item.label} + {kindLabel(item.kind)} + + } + /> + ))} + + {/* Add dialog */} + !open && closeAdd()} open={addOpen}> + + + {v.addTitle} + {v.addDescription} + + +
{ + e.preventDefault() + submitAdd() + }} + > +
+ + + + + setForm(f => ({ ...f, label: e.target.value }))} + placeholder={v.labelPlaceholder} + value={form.label} + /> + +
+ + {form.kind === 'login' && ( + <> + + setForm(f => ({ ...f, origin: e.target.value }))} + placeholder={v.originPlaceholder} + value={form.origin} + /> + +
+ + + + + setForm(f => ({ ...f, identifier: e.target.value }))} + value={form.identifier} + /> + +
+ + setForm(f => ({ ...f, password: e.target.value }))} + type="password" + value={form.password} + /> + + + )} + + {form.kind === 'payment' && ( + <> + + setForm(f => ({ ...f, cardNumber: e.target.value }))} + type="password" + value={form.cardNumber} + /> + + + setForm(f => ({ ...f, cardName: e.target.value }))} + value={form.cardName} + /> + +
+ + setForm(f => ({ ...f, expMonth: e.target.value }))} + placeholder="MM" + value={form.expMonth} + /> + + + setForm(f => ({ ...f, expYear: e.target.value }))} + placeholder="YYYY" + value={form.expYear} + /> + + + setForm(f => ({ ...f, cvc: e.target.value }))} + type="password" + value={form.cvc} + /> + + + setForm(f => ({ ...f, postal: e.target.value }))} + value={form.postal} + /> + +
+ + )} + + {form.kind === 'address' && ( + <> + + setForm(f => ({ ...f, line1: e.target.value }))} + value={form.line1} + /> + + + setForm(f => ({ ...f, line2: e.target.value }))} + value={form.line2} + /> + +
+ + setForm(f => ({ ...f, city: e.target.value }))} + value={form.city} + /> + + + setForm(f => ({ ...f, state: e.target.value }))} + value={form.state} + /> + +
+
+ + setForm(f => ({ ...f, postal: e.target.value }))} + value={form.postal} + /> + + + setForm(f => ({ ...f, country: e.target.value }))} + value={form.country} + /> + +
+ + )} + + {formError &&

{formError}

} + + + + + +
+
+
+ + {/* Delete confirmation */} + setPendingDelete(null)} + onConfirm={async () => { + if (pendingDelete) { + await deleteItem(pendingDelete) + } + }} + open={pendingDelete !== null} + title={v.deleteTitle} + /> +
+ ) +} diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index f7afe76382..bcdcaae0c4 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -382,7 +382,55 @@ export const ar = defineLocale({ archivedChats: 'المحادثات المؤرشفة', about: 'حول', notifications: 'الإشعارات', - keybinds: 'اختصارات لوحة المفاتيح' + keybinds: 'اختصارات لوحة المفاتيح', + vault: 'خزنة بيانات الاعتماد' + }, + vault: { + title: 'خزنة بيانات الاعتماد', + blurb: + 'بيانات اعتماد محلية مشفّرة يمكن للوكيل استخدامها لتسجيل الدخول إلى المواقع دون أن يرى كلمة المرور أبداً. تظهر التسميات والأصول ومعرّفات تسجيل الدخول؛ أما كلمات المرور فلا تظهر أبداً.', + count: n => `${n} محفوظة`, + loadFailed: 'تعذّر تحميل عناصر الخزنة', + empty: 'لا توجد بيانات اعتماد محفوظة بعد', + emptyDesc: + 'أضف تسجيل دخول ليتمكن الوكيل من الدخول إلى ذلك الموقع نيابةً عنك — يكتب اسم المستخدم بنفسه ويملأ كلمة المرور من الخزنة دون أن يراها أبداً.', + add: 'إضافة بيانات اعتماد', + addTitle: 'إضافة بيانات اعتماد', + addDescription: 'تُخزَّن مشفّرة على هذا الجهاز. لا يرى الوكيل كلمة المرور أبداً.', + added: 'تم حفظ بيانات الاعتماد في الخزنة.', + adding: 'جارٍ الحفظ…', + addConfirm: 'حفظ في الخزنة', + kindField: 'النوع', + kinds: { login: 'تسجيل دخول', payment: 'بطاقة دفع', address: 'عنوان' }, + labelField: 'التسمية', + labelPlaceholder: 'مثال: حساب GitHub للعمل', + labelRequired: 'التسمية مطلوبة.', + originField: 'أصل الموقع', + originPlaceholder: 'https://github.com', + originInvalid: 'أدخل عنوان URL صالحاً مثل https://example.com.', + identifierTypeField: 'نوع المعرّف', + identifierTypes: { email: 'البريد الإلكتروني', phone: 'الهاتف', username: 'اسم المستخدم' }, + identifierField: 'المعرّف', + identifierShown: identifier => identifier, + passwordField: 'كلمة المرور', + loginFieldsRequired: 'المعرّف وكلمة المرور مطلوبان.', + cardNumberField: 'رقم البطاقة', + cardNameField: 'الاسم على البطاقة', + expMonthField: 'شهر الانتهاء', + expYearField: 'سنة الانتهاء', + cvcField: 'CVC', + postalField: 'الرمز البريدي', + addressLine1Field: 'سطر العنوان 1', + addressLine2Field: 'سطر العنوان 2', + cityField: 'المدينة', + stateField: 'الولاية / المنطقة', + countryField: 'الدولة', + optional: '(اختياري)', + createdOn: date => `أُضيفت ${date}`, + deleteAction: 'حذف بيانات الاعتماد', + deleteTitle: 'حذف بيانات الاعتماد؟', + deleteDescription: label => `سيُزال "${label}" من الخزنة المشفّرة. لا يمكن التراجع عن هذا.`, + deleteConfirm: 'حذف' }, plugins: { title: 'إضافات سطح المكتب', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 1d5f0aadc1..2d67f1a656 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -439,7 +439,8 @@ export const en: Translations = { archivedChats: 'Archived Chats', about: 'About', billing: 'Billing', - notifications: 'Notifications' + notifications: 'Notifications', + vault: 'Credential Vault' }, plugins: { title: 'Desktop plugins', @@ -509,6 +510,53 @@ export const en: Translations = { missingEnv: vars => `Missing env vars: ${vars}. Add them in Settings → Keys.` } }, + vault: { + title: 'Credential Vault', + blurb: + 'Encrypted local credentials the agent can use to sign into sites without ever seeing the password. Labels, origins, and login identifiers are visible; passwords never are.', + count: n => `${n} saved`, + loadFailed: 'Could not load vault items', + empty: 'No saved credentials yet', + emptyDesc: + 'Add a login and the agent can sign into that site for you — it types the username itself and fills the password from the vault without ever seeing it.', + add: 'Add credential', + addTitle: 'Add credential', + addDescription: 'Stored encrypted on this machine. The agent never sees the password.', + added: 'Credential saved to the vault.', + adding: 'Saving…', + addConfirm: 'Save to vault', + kindField: 'Kind', + kinds: { login: 'Login', payment: 'Payment card', address: 'Address' }, + labelField: 'Label', + labelPlaceholder: 'e.g. GitHub work account', + labelRequired: 'A label is required.', + originField: 'Site origin', + originPlaceholder: 'https://github.com', + originInvalid: 'Enter a valid URL like https://example.com.', + identifierTypeField: 'Identifier type', + identifierTypes: { email: 'Email', phone: 'Phone', username: 'Username' }, + identifierField: 'Identifier', + identifierShown: identifier => identifier, + passwordField: 'Password', + loginFieldsRequired: 'Identifier and password are required.', + cardNumberField: 'Card number', + cardNameField: 'Name on card', + expMonthField: 'Exp. month', + expYearField: 'Exp. year', + cvcField: 'CVC', + postalField: 'Postal code', + addressLine1Field: 'Address line 1', + addressLine2Field: 'Address line 2', + cityField: 'City', + stateField: 'State / region', + countryField: 'Country', + optional: '(optional)', + createdOn: date => `Added ${date}`, + deleteAction: 'Delete credential', + deleteTitle: 'Delete credential?', + deleteDescription: label => `"${label}" will be removed from the encrypted vault. This cannot be undone.`, + deleteConfirm: 'Delete' + }, notifications: { title: 'Notifications', intro: 'OS notifications (not in-app toasts). Per device.', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index ef9d5f7ffc..3fa0978c69 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -328,7 +328,55 @@ export const ja = defineLocale({ archivedChats: 'アーカイブ済みチャット', about: '情報', billing: '請求', - notifications: '通知' + notifications: '通知', + vault: '資格情報ボールト' + }, + vault: { + title: '資格情報ボールト', + blurb: + 'エージェントがパスワードを一切見ることなくサイトへサインインするために使える、暗号化されたローカル資格情報です。ラベル・オリジン・ログイン識別子は表示されますが、パスワードは決して表示されません。', + count: n => `${n} 件保存済み`, + loadFailed: 'ボールト項目を読み込めませんでした', + empty: '保存された資格情報はまだありません', + emptyDesc: + 'ログインを追加すると、エージェントがそのサイトに代わりにサインインできます。ユーザー名はエージェント自身が入力し、パスワードはボールトから直接入力されるため、エージェントがパスワードを見ることはありません。', + add: '資格情報を追加', + addTitle: '資格情報を追加', + addDescription: 'このマシン上に暗号化して保存されます。エージェントがパスワードを見ることはありません。', + added: '資格情報をボールトに保存しました。', + adding: '保存中…', + addConfirm: 'ボールトに保存', + kindField: '種類', + kinds: { login: 'ログイン', payment: '支払いカード', address: '住所' }, + labelField: 'ラベル', + labelPlaceholder: '例: GitHub 仕事用アカウント', + labelRequired: 'ラベルは必須です。', + originField: 'サイトのオリジン', + originPlaceholder: 'https://github.com', + originInvalid: 'https://example.com のような有効な URL を入力してください。', + identifierTypeField: '識別子の種類', + identifierTypes: { email: 'メール', phone: '電話番号', username: 'ユーザー名' }, + identifierField: '識別子', + identifierShown: identifier => identifier, + passwordField: 'パスワード', + loginFieldsRequired: '識別子とパスワードは必須です。', + cardNumberField: 'カード番号', + cardNameField: 'カード名義', + expMonthField: '有効期限(月)', + expYearField: '有効期限(年)', + cvcField: 'CVC', + postalField: '郵便番号', + addressLine1Field: '住所 1 行目', + addressLine2Field: '住所 2 行目', + cityField: '市区町村', + stateField: '都道府県 / 地域', + countryField: '国', + optional: '(任意)', + createdOn: date => `追加日 ${date}`, + deleteAction: '資格情報を削除', + deleteTitle: '資格情報を削除しますか?', + deleteDescription: label => `「${label}」は暗号化ボールトから削除されます。元に戻せません。`, + deleteConfirm: '削除' }, notifications: { title: '通知', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 20f786044a..bc7cef7427 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -382,6 +382,7 @@ export interface Translations { about: string billing: string notifications: string + vault: string } plugins: { title: string @@ -445,6 +446,51 @@ export interface Translations { missingEnv: (vars: string) => string } } + vault: { + title: string + blurb: string + count: (n: number) => string + loadFailed: string + empty: string + emptyDesc: string + add: string + addTitle: string + addDescription: string + added: string + adding: string + addConfirm: string + kindField: string + kinds: Record<'address' | 'login' | 'payment', string> + labelField: string + labelPlaceholder: string + labelRequired: string + originField: string + originPlaceholder: string + originInvalid: string + identifierTypeField: string + identifierTypes: Record<'email' | 'phone' | 'username', string> + identifierField: string + identifierShown: (identifier: string) => string + passwordField: string + loginFieldsRequired: string + cardNumberField: string + cardNameField: string + expMonthField: string + expYearField: string + cvcField: string + postalField: string + addressLine1Field: string + addressLine2Field: string + cityField: string + stateField: string + countryField: string + optional: string + createdOn: (date: string) => string + deleteAction: string + deleteTitle: string + deleteDescription: (label: string) => string + deleteConfirm: string + } notifications: { title: string intro: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 0dc3060c68..e8a1885f20 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -319,7 +319,53 @@ export const zhHant = defineLocale({ archivedChats: '已封存聊天', about: '關於', billing: '帳單', - notifications: '通知' + notifications: '通知', + vault: '憑證保險庫' + }, + vault: { + title: '憑證保險庫', + blurb: '加密儲存在本機的憑證,代理可用它們登入網站,但永遠看不到密碼。標籤、網站來源與登入識別碼可見;密碼永不可見。', + count: n => `已儲存 ${n} 項`, + loadFailed: '無法載入保險庫項目', + empty: '尚未儲存任何憑證', + emptyDesc: '新增一組登入憑證後,代理即可代你登入該網站——它會自行輸入使用者名稱,並從保險庫直接填入密碼,全程看不到密碼。', + add: '新增憑證', + addTitle: '新增憑證', + addDescription: '加密儲存在此裝置上。代理永遠不會看到密碼。', + added: '憑證已儲存到保險庫。', + adding: '儲存中…', + addConfirm: '儲存到保險庫', + kindField: '類型', + kinds: { login: '登入', payment: '支付卡', address: '地址' }, + labelField: '標籤', + labelPlaceholder: '例如:GitHub 工作帳號', + labelRequired: '標籤為必填。', + originField: '網站來源', + originPlaceholder: 'https://github.com', + originInvalid: '請輸入有效的 URL,例如 https://example.com。', + identifierTypeField: '識別碼類型', + identifierTypes: { email: '電子郵件', phone: '電話', username: '使用者名稱' }, + identifierField: '識別碼', + identifierShown: identifier => identifier, + passwordField: '密碼', + loginFieldsRequired: '識別碼與密碼為必填。', + cardNumberField: '卡號', + cardNameField: '持卡人姓名', + expMonthField: '到期月份', + expYearField: '到期年份', + cvcField: 'CVC', + postalField: '郵遞區號', + addressLine1Field: '地址第 1 行', + addressLine2Field: '地址第 2 行', + cityField: '城市', + stateField: '州 / 地區', + countryField: '國家/地區', + optional: '(選填)', + createdOn: date => `新增於 ${date}`, + deleteAction: '刪除憑證', + deleteTitle: '刪除憑證?', + deleteDescription: label => `「${label}」將從加密保險庫中移除。此操作無法復原。`, + deleteConfirm: '刪除' }, notifications: { title: '通知', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 0fadf697cf..173f2ded97 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -425,7 +425,53 @@ export const zh: Translations = { archivedChats: '已归档对话', about: '关于', billing: '账单', - notifications: '通知' + notifications: '通知', + vault: '凭据保险库' + }, + vault: { + title: '凭据保险库', + blurb: '加密存储在本地的凭据,代理可用它们登录网站,但永远看不到密码。标签、站点来源和登录标识符可见;密码永不可见。', + count: n => `已保存 ${n} 项`, + loadFailed: '无法加载保险库条目', + empty: '尚未保存任何凭据', + emptyDesc: '添加一个登录凭据后,代理即可代你登录该网站——它会自行输入用户名,并从保险库中直接填入密码,全程看不到密码。', + add: '添加凭据', + addTitle: '添加凭据', + addDescription: '加密保存在本机。代理永远不会看到密码。', + added: '凭据已保存到保险库。', + adding: '保存中…', + addConfirm: '保存到保险库', + kindField: '类型', + kinds: { login: '登录', payment: '支付卡', address: '地址' }, + labelField: '标签', + labelPlaceholder: '例如:GitHub 工作账号', + labelRequired: '标签为必填项。', + originField: '站点来源', + originPlaceholder: 'https://github.com', + originInvalid: '请输入有效的 URL,例如 https://example.com。', + identifierTypeField: '标识符类型', + identifierTypes: { email: '邮箱', phone: '电话', username: '用户名' }, + identifierField: '标识符', + identifierShown: identifier => identifier, + passwordField: '密码', + loginFieldsRequired: '标识符和密码为必填项。', + cardNumberField: '卡号', + cardNameField: '持卡人姓名', + expMonthField: '到期月份', + expYearField: '到期年份', + cvcField: 'CVC', + postalField: '邮政编码', + addressLine1Field: '地址第 1 行', + addressLine2Field: '地址第 2 行', + cityField: '城市', + stateField: '省 / 州', + countryField: '国家/地区', + optional: '(可选)', + createdOn: date => `添加于 ${date}`, + deleteAction: '删除凭据', + deleteTitle: '删除凭据?', + deleteDescription: label => `“${label}”将从加密保险库中移除。此操作无法撤销。`, + deleteConfirm: '删除' }, plugins: { title: '桌面插件', diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index 3f63c54dd9..a3e72206d3 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -106,6 +106,7 @@ import { IconSend as Send, IconSettings as Settings, IconSettings2 as Settings2, + IconShieldLock as ShieldLock, IconAdjustmentsHorizontal as SlidersHorizontal, IconMoodPlus as SmilePlusIcon, IconSquare as Square, @@ -238,6 +239,7 @@ export { Send, Settings, Settings2, + ShieldLock, SlidersHorizontal, SmilePlusIcon, Square, diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index fda440d66e..fd7b299996 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -96,7 +96,10 @@ _EXCLUDED_PREFIXES = ("state.db.pre-update-emergency-",) _IMPORT_SKIP_NAMES = {"gateway_state.json", "gateway.pid", "cron.pid", "gateway.lock", "processes.json"} # zipfile.open() drops Unix mode bits on extract; restore tightens these to 0600. -_SECRET_FILE_NAMES = {".env", "auth.json", "state.db"} +# vault.key / vault.json.enc: the local credential vault (agent/vault_store.py) +# IS included in backups (user-entered secrets, not regenerable — unlike the +# excluded browser-profile/ snapshot) but must come back owner-only. +_SECRET_FILE_NAMES = {".env", "auth.json", "state.db", "vault.key", "vault.json.enc"} # Reserved archive subtree for memory-provider state OUTSIDE HERMES_HOME (e.g. ~/.honcho, via # MemoryProvider.backup_paths()), stored and restored relative to the user's home; paths not diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 151ecc5991..29af92d01e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -358,6 +358,7 @@ from hermes_cli.subcommands.pairing import build_pairing_parser from hermes_cli.subcommands.plugins import build_plugins_parser from hermes_cli.subcommands.mcp import build_mcp_parser from hermes_cli.subcommands.claw import build_claw_parser +from hermes_cli.subcommands.vault import build_vault_parser from hermes_cli.subcommands.moa import build_moa_parser from hermes_cli.subcommands.fallback import build_fallback_parser from hermes_cli.subcommands.worktree import build_worktree_parser @@ -2628,6 +2629,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "resume", "send", "sessions", "setup", "skin", "skills", "slack", "status", "sync", "tools", "uninstall", "update", + "vault", "webhook", "whatsapp", "whatsapp-cloud", "worktree", "chat", "secrets", "security", "browser", "verify", @@ -3270,6 +3272,7 @@ def _build_cli_parser(): build_insights_parser(subparsers, cmd_insights=cmd_insights) build_monitoring_parser(subparsers, cmd_monitoring=cmd_monitoring) build_claw_parser(subparsers, cmd_claw=cmd_claw) + build_vault_parser(subparsers) build_update_parser(subparsers, cmd_update=cmd_update) build_uninstall_parser(subparsers, cmd_uninstall=cmd_uninstall) build_acp_parser(subparsers, cmd_acp=cmd_acp) diff --git a/hermes_cli/subcommands/vault.py b/hermes_cli/subcommands/vault.py new file mode 100644 index 0000000000..90c79df6f1 --- /dev/null +++ b/hermes_cli/subcommands/vault.py @@ -0,0 +1,21 @@ +"""``hermes vault`` subcommand parser.""" + +from __future__ import annotations + + +def build_vault_parser(subparsers) -> None: + """Attach the local encrypted autofill vault subcommand.""" + vault_parser = subparsers.add_parser( + "vault", + help="Manage the local encrypted autofill vault (add/list/rm credentials)", + description=( + "Store login credentials in a locally encrypted vault. The agent " + "sees handles and login identifiers (metadata); passwords are " + "injected server-side by browser_vault_fill on the exact origin " + "they were saved for and never enter the conversation." + ), + ) + from hermes_cli.vault import register_cli, vault_command + + register_cli(vault_parser) + vault_parser.set_defaults(func=vault_command) diff --git a/hermes_cli/vault.py b/hermes_cli/vault.py new file mode 100644 index 0000000000..9deaa7b0d4 --- /dev/null +++ b/hermes_cli/vault.py @@ -0,0 +1,174 @@ +"""``hermes vault`` — manage the local encrypted autofill vault. + +Subcommands: +- ``hermes vault add`` interactive wizard; the password is read via + getpass (never echoed, never accepted as argv). The login identifier is + visible metadata and prompted normally. +- ``hermes vault list`` metadata — labels, kinds, identifiers, origins, + handles. Passwords are never shown. +- ``hermes vault rm`` remove an item by handle/id. + +The vault backs the password-blind browser autofill tools +(``browser_vault_list`` / ``browser_vault_fill``): the agent sees handles +and login identifiers, types the identifier itself, and fills the password +server-side without ever seeing it. +""" + +from __future__ import annotations + +import getpass + + +def _console(): + from rich.console import Console + + return Console() + + +def _cmd_add(args) -> None: + from agent.vault_store import ( + LOGIN_IDENTIFIER_TYPES, + VAULT_KINDS, + VaultError, + get_vault_store, + ) + + c = _console() + c.print( + "[bold]Add a vault item[/] (the password is encrypted at rest and the " + "agent never sees it; the identifier is visible metadata the agent " + "can type itself)" + ) + + kind = (args.kind or "").strip().lower() + while kind not in VAULT_KINDS: + kind = input(f"Kind ({'/'.join(VAULT_KINDS)}) [login]: ").strip().lower() or "login" + if kind not in VAULT_KINDS: + c.print(f"[red]Unknown kind {kind!r}[/]") + kind = "" + + label = "" + while not label: + label = input("Label (e.g. 'GitHub work account'): ").strip() + + try: + if kind == "login": + origin = "" + while not origin: + origin = input("Site origin (e.g. https://github.com): ").strip() + id_type = "" + while id_type not in LOGIN_IDENTIFIER_TYPES: + id_type = ( + input(f"Identifier type ({'/'.join(LOGIN_IDENTIFIER_TYPES)}) [email]: ") + .strip() + .lower() + or "email" + ) + identifier = "" + while not identifier: + identifier = input(f"{id_type.capitalize()}: ").strip() + password = "" + while not password: + password = getpass.getpass("Password (hidden): ") + # identifier_type/identifier are stored as metadata (not secret); + # add_item moves them out of the encrypted payload. + secret = { + "identifier_type": id_type, + "identifier": identifier, + "password": password, + } + meta = get_vault_store().add_item( + kind="login", label=label, secret=secret, origin=origin + ) + else: + c.print( + f"[dim]{kind} items are stored for future phases; browser fill " + "currently supports login items only.[/]" + ) + secret = {} + c.print("Enter fields one per line as name=value; blank line to finish.") + c.print("[dim]Values are read hidden (not echoed).[/]") + while True: + field = input("Field name (blank to finish): ").strip() + if not field: + break + secret[field] = getpass.getpass(f"{field} (hidden): ") + origin = input("Origin (optional, e.g. https://shop.example.com): ").strip() or None + meta = get_vault_store().add_item( + kind=kind, label=label, secret=secret, origin=origin + ) + except VaultError as exc: + c.print(f"[red]Error:[/] {exc}") + return + + c.print(f"[green]Stored.[/] handle=[bold]{meta.id}[/] kind={meta.kind} origin={meta.origin or '-'}") + + +def _cmd_list(args) -> None: + from agent.vault_store import get_vault_store + + c = _console() + items = get_vault_store().list_items() + if not items: + c.print("[dim]Vault is empty. Add an item with `hermes vault add`.[/]") + return + from rich.table import Table + + table = Table(title=f"Vault items ({len(items)})") + table.add_column("Handle", style="bold") + table.add_column("Kind") + table.add_column("Label") + table.add_column("Identifier") + table.add_column("Origin") + table.add_column("Created") + for meta in items: + table.add_row( + meta.id, + meta.kind, + meta.label, + meta.identifier or "-", + meta.origin or "-", + meta.created_at[:19], + ) + c.print(table) + c.print("[dim]Passwords are never shown; the agent fills them server-side from the handle.[/]") + + +def _cmd_rm(args) -> None: + from agent.vault_store import get_vault_store + + c = _console() + if get_vault_store().remove_item(args.handle): + c.print(f"[green]Removed[/] {args.handle}") + else: + c.print(f"[red]No vault item with handle {args.handle!r}[/]") + + +def register_cli(subparser) -> None: + """Build the ``hermes vault`` argparse tree (called from main.py).""" + subs = subparser.add_subparsers(dest="vault_action") + + p_add = subs.add_parser( + "add", + help="Add a credential to the vault (interactive; secrets never echoed)", + ) + p_add.add_argument( + "--kind", choices=["login", "payment", "address"], default=None, + help="Item kind (interactive prompt when omitted)", + ) + p_add.set_defaults(_vault_handler=_cmd_add) + + p_list = subs.add_parser("list", help="List vault items (metadata only, never values)") + p_list.set_defaults(_vault_handler=_cmd_list) + + p_rm = subs.add_parser("rm", help="Remove a vault item by handle") + p_rm.add_argument("handle", help="Item handle (see `hermes vault list`)") + p_rm.set_defaults(_vault_handler=_cmd_rm) + + +def vault_command(args) -> None: + handler = getattr(args, "_vault_handler", None) + if handler is None: + _cmd_list(args) + return + handler(args) diff --git a/tests/test_browser_vault.py b/tests/test_browser_vault.py new file mode 100644 index 0000000000..488e1e520b --- /dev/null +++ b/tests/test_browser_vault.py @@ -0,0 +1,515 @@ +"""Tests for the vault-backed password-blind browser autofill feature. + +Covers: +- VaultStore: encrypt/decrypt round-trip, file perms, identifier-as-metadata + (login secret payload is password-only) +- login-control classifier: scoring + new-password/one-time-code exclusion, + password-only fill selection +- origin-binding refusal (pre-check + in-script TOCTOU assert) +- fail-closed secret eval (no argv fallback) +- vault-value redaction registry (browser_cdp read-back regression) +- tool gating: check_fn False when the vault is empty +""" + +from __future__ import annotations + +import json +import os +import stat +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from agent.vault_login_classifier import ( # noqa: E402 + ClassifiedLoginControl, + LoginControl, + build_fill_js, + classify_login_control, + select_password_fill, +) +from agent.vault_store import ( # noqa: E402 + VaultError, + VaultStore, + normalize_origin, + scrub_secret_from_text, +) + + +@pytest.fixture() +def store(tmp_path): + return VaultStore(base_dir=tmp_path / "vault") + + +def _add_login(store, origin="https://example.com", password="s3cret-pw"): + return store.add_item( + kind="login", + label="Example login", + origin=origin, + secret={ + "identifier_type": "email", + "identifier": "user@example.com", + "password": password, + "origin": origin, + }, + ) + + +# --------------------------------------------------------------------------- +# VaultStore +# --------------------------------------------------------------------------- + +class TestVaultStore: + def test_roundtrip_encrypt_decrypt(self, store): + meta = _add_login(store) + secret = store.resolve_secret(meta.id) + # Design: login secret payload is password-only; identifier is metadata. + assert secret == {"password": "s3cret-pw"} + assert meta.identifier == "user@example.com" + assert meta.identifier_type == "email" + + def test_vault_file_never_contains_password(self, store, tmp_path): + _add_login(store) + blob = (tmp_path / "vault" / "vault.json.enc").read_bytes() + assert b"s3cret-pw" not in blob + + def test_file_permissions_0600(self, store, tmp_path): + _add_login(store) + for name in ("vault.json.enc", "vault.key"): + mode = stat.S_IMODE(os.stat(tmp_path / "vault" / name).st_mode) + assert mode == 0o600, f"{name} has mode {oct(mode)}" + + def test_listing_is_password_free(self, store): + meta = _add_login(store) + items = store.list_items() + assert len(items) == 1 + dumped = json.dumps(items[0].to_dict()) + assert "s3cret-pw" not in dumped + assert "password" not in dumped + # Identifier IS visible metadata now. + assert items[0].identifier == "user@example.com" + assert items[0].identifier_type == "email" + assert items[0].id == meta.id + assert items[0].origin == "https://example.com" + + def test_remove_item(self, store): + meta = _add_login(store) + assert store.remove_item(meta.id) is True + assert store.remove_item(meta.id) is False + assert store.list_items() == [] + + def test_login_requires_origin(self, store): + with pytest.raises(VaultError): + store.add_item( + kind="login", + label="x", + secret={ + "identifier_type": "email", + "identifier": "a@b.c", + "password": "p", + }, + ) + + def test_all_kinds_supported(self, store): + store.add_item(kind="payment", label="Card", secret={"number": "4111"}) + store.add_item(kind="address", label="Home", secret={"street": "1 Main St"}) + kinds = {m.kind for m in store.list_items()} + assert kinds == {"payment", "address"} + + def test_unknown_kind_rejected(self, store): + with pytest.raises(VaultError): + store.add_item(kind="totp", label="x", secret={}) + + def test_has_items(self, store): + assert store.has_items() is False + _add_login(store) + assert store.has_items() is True + + def test_normalize_origin(self): + assert normalize_origin("https://Example.com:443/login?x=1") == "https://example.com" + assert normalize_origin("http://localhost:8931/") == "http://localhost:8931" + assert normalize_origin("http://site.test:80") == "http://site.test" + with pytest.raises(VaultError): + normalize_origin("example.com") + + def test_scrub_secret_from_text(self): + secret = {"password": "hunter22x", "identifier": "me@x.io"} + out = scrub_secret_from_text("boom hunter22x at me@x.io", secret) + assert "hunter22x" not in out + assert "me@x.io" not in out + + +# --------------------------------------------------------------------------- +# Classifier +# --------------------------------------------------------------------------- + +def _ctrl(**kw): + base = dict(autocomplete="", form_index=0, index=0, label="", name="", type="text") + base.update(kw) + return LoginControl(**base) + + +class TestClassifier: + def test_autocomplete_exact_match_scores_100(self): + for token in ("username", "email", "tel", "current-password"): + res = classify_login_control(_ctrl(autocomplete=token)) + assert res is not None and res.score == 100 and res.token == token + + def test_new_password_autocomplete_excluded(self): + assert classify_login_control( + _ctrl(autocomplete="new-password", type="password") + ) is None + + def test_one_time_code_excluded(self): + assert classify_login_control(_ctrl(autocomplete="one-time-code")) is None + + def test_label_new_password_excluded(self): + for label in ("New password", "Confirm Password", "create-password", "Repeat password"): + assert classify_login_control(_ctrl(type="password", label=label)) is None, label + + def test_password_type_scores_90(self): + res = classify_login_control(_ctrl(type="password")) + assert res.score == 90 and res.token == "current-password" + + def test_email_tel_types_score_85(self): + assert classify_login_control(_ctrl(type="email")).score == 85 + res = classify_login_control(_ctrl(type="tel")) + assert res.score == 85 and res.token == "tel" + + def test_label_heuristics(self): + assert classify_login_control(_ctrl(label="E-mail address")).token == "email" + assert classify_login_control(_ctrl(name="mobile_number")).token == "tel" + res = classify_login_control(_ctrl(label="Username or account")) + assert res.token == "username" and res.score == 70 + + def test_unmatched_returns_none(self): + assert classify_login_control(_ctrl(label="Search the docs")) is None + + def test_select_password_fill_picks_best_password(self): + user = ClassifiedLoginControl(_ctrl(index=0, form_index=0, autocomplete="username"), 100, "username") + pw_heur = ClassifiedLoginControl(_ctrl(index=1, form_index=0, type="password"), 90, "current-password") + pw_exact = ClassifiedLoginControl(_ctrl(index=3, form_index=0, autocomplete="current-password"), 100, "current-password") + fills = select_password_fill([user, pw_heur, pw_exact], "p") + # Password only — the identifier field is never filled by the vault. + assert [(f["index"], f["token"]) for f in fills] == [(3, "current-password")] + + def test_select_password_fill_requires_password_field(self): + user = ClassifiedLoginControl(_ctrl(index=0, autocomplete="username"), 100, "username") + assert select_password_fill([user], "p") == [] + + def test_select_password_fill_single_field_only(self): + pw1 = ClassifiedLoginControl(_ctrl(index=1, type="password"), 90, "current-password") + pw2 = ClassifiedLoginControl(_ctrl(index=2, type="password"), 90, "current-password") + fills = select_password_fill([pw1, pw2], "p") + assert len(fills) == 1 and fills[0]["index"] == 1 + + def test_build_fill_js_contains_events(self): + js = build_fill_js( + [{"index": 0, "token": "current-password", "value": "x"}], + expected_origin="https://example.com", + ) + assert "InputEvent" in js and '"change"' in js and "filled" in js + + def test_build_fill_js_has_no_dom_marker(self): + # P1-1: no deterministic selector for filled controls. + js = build_fill_js( + [{"index": 0, "token": "current-password", "value": "x"}], + expected_origin="https://example.com", + ) + assert "vaultSecret" not in js + assert "data-vault-secret" not in js + + def test_build_fill_js_asserts_origin_before_any_write(self): + # P1-2: the origin assert must run inside the SAME script, before + # any element write. + js = build_fill_js( + [{"index": 0, "token": "current-password", "value": "x"}], + expected_origin="https://example.com", + ) + assert '"https://example.com"' in js + assert "window.location.origin" in js + assert "origin_changed" in js + assert js.index("origin_changed") < js.index("querySelectorAll") + + +# --------------------------------------------------------------------------- +# Browser tool: origin binding + gating +# --------------------------------------------------------------------------- + +class TestBrowserVaultTools: + def test_check_fn_false_when_vault_empty(self, tmp_path): + from tools import browser_vault_tool + + empty = VaultStore(base_dir=tmp_path / "empty-vault") + with patch("agent.vault_store.get_vault_store", return_value=empty): + assert browser_vault_tool._check_vault_available() is False + + def test_check_fn_true_with_items(self, store): + from tools import browser_vault_tool + + _add_login(store) + with patch("agent.vault_store.get_vault_store", return_value=store): + assert browser_vault_tool._check_vault_available() is True + + def test_list_returns_identifier_never_password(self, store): + from tools import browser_vault_tool + + _add_login(store) + with patch("agent.vault_store.get_vault_store", return_value=store): + out = json.loads(browser_vault_tool.browser_vault_list()) + assert out["success"] is True + assert out["items"][0]["handle"].startswith("vault_") + # Design change: identifier is agent-visible metadata. + assert out["items"][0]["identifier"] == "user@example.com" + assert out["items"][0]["identifier_type"] == "email" + assert "s3cret-pw" not in json.dumps(out) + + def test_fill_refused_on_origin_mismatch(self, store): + from tools import browser_vault_tool + + meta = _add_login(store, origin="https://example.com") + with patch("agent.vault_store.get_vault_store", return_value=store), \ + patch.object(browser_vault_tool, "_current_page_origin", return_value="https://evil.com"): + out = json.loads(browser_vault_tool.browser_vault_fill(meta.id)) + assert out["success"] is False + assert "Refused" in out["error"] + assert "s3cret-pw" not in json.dumps(out) + + def test_fill_unknown_handle(self, store): + from tools import browser_vault_tool + + with patch("agent.vault_store.get_vault_store", return_value=store): + out = json.loads(browser_vault_tool.browser_vault_fill("vault_nope")) + assert out["success"] is False + + def test_fill_success_returns_counts_only(self, store): + from tools import browser_vault_tool + + meta = _add_login(store, origin="https://example.com") + controls = [ + {"autocomplete": "email", "formIndex": 0, "index": 0, "label": "", "name": "email", "type": "email"}, + {"autocomplete": "current-password", "formIndex": 0, "index": 1, "label": "", "name": "pw", "type": "password"}, + ] + + def fake_eval(task_id, expression): + if "location.href" in expression: + return {"success": True, "result": "https://example.com/login"} + return {"success": True, "result": json.dumps(controls)} + + secret_exprs = [] + + def fake_eval_secret(task_id, expression): + secret_exprs.append(expression) + return {"success": True, "result": json.dumps({"filled": 1})} + + with patch("agent.vault_store.get_vault_store", return_value=store), \ + patch.object(browser_vault_tool, "_eval_js", side_effect=fake_eval), \ + patch.object(browser_vault_tool, "_eval_js_secret", side_effect=fake_eval_secret): + raw = browser_vault_tool.browser_vault_fill(meta.id) + out = json.loads(raw) + # Password-only fill: exactly one field. + assert out == { + "success": True, + "filled_fields": 1, + "kind": "login", + "origin": "https://example.com", + } + assert "s3cret-pw" not in raw + # The secret expression only ever goes through the secret eval path, + # and it targets only the password field (index 1). + assert len(secret_exprs) == 1 + assert "s3cret-pw" in secret_exprs[0] + assert '"index": 0' not in secret_exprs[0] + assert "user@example.com" not in secret_exprs[0] + + def test_fill_toctou_navigation_writes_nothing(self, store): + """P1-2 schedule regression: inspection passes on the allowed origin, + the page navigates before the fill script runs, the in-script origin + assert refuses, and zero credential bytes are written.""" + from tools import browser_vault_tool + + meta = _add_login(store, origin="https://example.com") + controls = [ + {"autocomplete": "current-password", "formIndex": 0, "index": 0, "label": "", "name": "pw", "type": "password"}, + ] + + def fake_eval(task_id, expression): + if "location.href" in expression: + # Pre-check sees the allowed origin. + return {"success": True, "result": "https://example.com/login"} + return {"success": True, "result": json.dumps(controls)} + + def fake_eval_secret(task_id, expression): + # The evaluated script itself must carry the origin assert. + assert "window.location.origin" in expression + assert '"https://example.com"' in expression + # Simulate the page having navigated cross-origin by the time + # the fill script executes: the script's own assert fires. + return { + "success": True, + "result": json.dumps( + {"refused": "origin_changed", "found": "https://evil.com"} + ), + } + + with patch("agent.vault_store.get_vault_store", return_value=store), \ + patch.object(browser_vault_tool, "_eval_js", side_effect=fake_eval), \ + patch.object(browser_vault_tool, "_eval_js_secret", side_effect=fake_eval_secret): + raw = browser_vault_tool.browser_vault_fill(meta.id) + out = json.loads(raw) + assert out["success"] is False + assert out["error_type"] == "origin_changed" + assert out.get("filled_fields", 0) == 0 + assert "s3cret-pw" not in raw + + def test_secret_eval_fails_closed_without_supervisor(self, store): + """P1-1: the secret-bearing eval NEVER falls back to the argv path.""" + from tools import browser_vault_tool + + meta = _add_login(store, origin="https://example.com") + controls = [ + {"autocomplete": "current-password", "formIndex": 0, "index": 0, "label": "", "name": "pw", "type": "password"}, + ] + + def fake_eval(task_id, expression): + if "location.href" in expression: + return {"success": True, "result": "https://example.com/login"} + return {"success": True, "result": json.dumps(controls)} + + # No supervisor registered → _eval_js_secret must refuse without + # ever touching _run_browser_command. + with patch("agent.vault_store.get_vault_store", return_value=store), \ + patch.object(browser_vault_tool, "_eval_js", side_effect=fake_eval), \ + patch("tools.browser_supervisor.SUPERVISOR_REGISTRY") as reg, \ + patch("tools.browser_tool_session._run_browser_command") as run_cmd: + reg.get.return_value = None + raw = browser_vault_tool.browser_vault_fill(meta.id) + out = json.loads(raw) + assert out["success"] is False + assert out["error_type"] == "supervisor_required" + assert "supervis" in out["error"].lower() + run_cmd.assert_not_called() + assert "s3cret-pw" not in raw + + def test_nonsecret_eval_fallback_still_works(self): + """_eval_js (non-secret) may still fall back to the CLI eval path.""" + from tools import browser_vault_tool + + with patch("tools.browser_supervisor.SUPERVISOR_REGISTRY") as reg, \ + patch("tools.browser_tool._last_session_key", return_value="k"), \ + patch("tools.browser_tool_session._run_browser_command") as run_cmd: + reg.get.return_value = None + run_cmd.return_value = {"success": True, "data": {"result": "https://x.test"}} + res = browser_vault_tool._eval_js("t", "window.location.href") + assert res == {"success": True, "result": "https://x.test"} + run_cmd.assert_called_once() + + def test_vault_canary_redacted_from_browser_cdp_results(self, store): + """P1-1 regression: a filled, non-token-shaped canary password must be + unrecoverable through a model-facing browser_cdp-style result.""" + from agent import redact + from agent.redact import redact_sensitive_text + from tools import browser_vault_tool + from tools.browser_cdp_tool import _redact_cdp_output + + canary = "plain sentence nobody would flag 7" + meta = _add_login(store, origin="https://example.com", password=canary) + controls = [ + {"autocomplete": "current-password", "formIndex": 0, "index": 0, "label": "", "name": "pw", "type": "password"}, + ] + + def fake_eval(task_id, expression): + if "location.href" in expression: + return {"success": True, "result": "https://example.com/login"} + return {"success": True, "result": json.dumps(controls)} + + def fake_eval_secret(task_id, expression): + return {"success": True, "result": json.dumps({"filled": 1})} + + try: + with patch("agent.vault_store.get_vault_store", return_value=store), \ + patch.object(browser_vault_tool, "_eval_js", side_effect=fake_eval), \ + patch.object(browser_vault_tool, "_eval_js_secret", side_effect=fake_eval_secret): + out = json.loads(browser_vault_tool.browser_vault_fill(meta.id)) + assert out["success"] is True + + # Simulate a browser_cdp Runtime.evaluate sibling read echoing + # the canary back (e.g. reading the input's value from the DOM). + cdp_result = { + "result": {"type": "string", "value": canary}, + "description": f"input value is {canary}", + } + scrubbed = _redact_cdp_output(cdp_result) + assert canary not in json.dumps(scrubbed) + assert "«redacted-vault-secret»" in json.dumps(scrubbed, ensure_ascii=False) + + # And the generic browser-result scrub catches it too, even with + # user-level redaction preferences irrelevant (unconditional). + assert canary not in redact_sensitive_text(f"page text: {canary}") + finally: + with redact._VAULT_REDACTION_LOCK: + redact._VAULT_REDACTION_VALUES.discard(canary) + + def test_fill_rejects_non_login_kind(self, store): + from tools import browser_vault_tool + + meta = store.add_item(kind="payment", label="Card", secret={"number": "4111"}) + with patch("agent.vault_store.get_vault_store", return_value=store): + out = json.loads(browser_vault_tool.browser_vault_fill(meta.id)) + assert out["success"] is False + assert "login" in out["error"] + + +class TestVaultHardening: + """Read-deny, backup perms-tightening, canonical dir securing. + + Mirrors the browser-profile snapshot hardening (f1d05c): the vault dir + holds key + ciphertext side by side, so it gets the same treatment. + """ + + def test_read_block_vault_dir_and_contents(self, tmp_path, monkeypatch): + import agent.file_safety as fs + + home = tmp_path / "hermes_home" + vault = home / "vault" + vault.mkdir(parents=True) + (vault / "vault.key").write_text("k") + (vault / "vault.json.enc").write_text("blob") + monkeypatch.setattr(fs, "_hermes_home_path", lambda: home) + + for target in (vault, vault / "vault.key", vault / "vault.json.enc"): + err = fs.get_read_block_error(str(target)) + assert err is not None, f"expected read deny for {target}" + assert "vault" in err.lower() + + def test_read_block_leaves_sibling_dirs_alone(self, tmp_path, monkeypatch): + import agent.file_safety as fs + + home = tmp_path / "hermes_home" + other = home / "vaults-notes" + other.mkdir(parents=True) + f = other / "notes.txt" + f.write_text("hi") + monkeypatch.setattr(fs, "_hermes_home_path", lambda: home) + assert fs.get_read_block_error(str(f)) is None + + def test_backup_secret_names_include_vault_files(self): + from hermes_cli.backup import _SECRET_FILE_NAMES + + assert "vault.key" in _SECRET_FILE_NAMES + assert "vault.json.enc" in _SECRET_FILE_NAMES + + def test_ensure_dir_uses_canonical_secure_dir(self, tmp_path, monkeypatch): + from unittest.mock import MagicMock + + import hermes_cli.config as cfg + from agent.vault_store import VaultStore + + called = MagicMock() + monkeypatch.setattr(cfg, "_secure_dir", called) + store = VaultStore(base_dir=tmp_path / "vault") + store._ensure_dir() + assert called.call_count == 1 diff --git a/tests/tui_gateway/test_vault_methods.py b/tests/tui_gateway/test_vault_methods.py new file mode 100644 index 0000000000..82c1e6bc5a --- /dev/null +++ b/tests/tui_gateway/test_vault_methods.py @@ -0,0 +1,114 @@ +"""Tests: vault.* JSON-RPC handlers (tui_gateway/methods_vault.py). + +The Desktop's Settings → Credential Vault panel. Contracts: +- vault.list returns metadata only — secret values must never appear in + any response envelope; +- vault.add validates via VaultStore.add_item and surfaces clean, + secret-free error messages; +- vault.remove reports {removed: bool} idempotently. +""" + +from __future__ import annotations + +import json + +import pytest + +import tui_gateway.server as srv + + +@pytest.fixture +def home(tmp_path, monkeypatch): + h = tmp_path / ".hermes" + h.mkdir() + monkeypatch.setenv("HERMES_HOME", str(h)) + return h + + +def _result(envelope): + assert "error" not in envelope, envelope + return envelope["result"] + + +def _error(envelope): + assert "error" in envelope, envelope + return envelope["error"] + + +_LOGIN_PARAMS = { + "kind": "login", + "label": "Example login", + "origin": "https://example.com", + "secret": { + "identifier_type": "email", + "identifier": "user@example.com", + "password": "s3cret-pw-9000", + }, +} + + +def test_add_then_list_is_password_free(home): + out = _result(srv._methods["vault.add"](1, dict(_LOGIN_PARAMS))) + assert out["id"].startswith("vault_") + # add's own envelope must not echo the secret back + assert "s3cret-pw-9000" not in json.dumps(out) + + listed = _result(srv._methods["vault.list"](2, {})) + assert len(listed["items"]) == 1 + item = listed["items"][0] + assert item["id"] == out["id"] + assert item["kind"] == "login" + assert item["label"] == "Example login" + assert item["origin"] == "https://example.com" + assert item["created_at"] + # Identifier is agent-visible metadata (design: only the password is secret). + assert item["identifier"] == "user@example.com" + assert item["identifier_type"] == "email" + dumped = json.dumps(listed) + assert "s3cret-pw-9000" not in dumped + assert "password" not in dumped + + +def test_add_validation_errors_are_clean(home): + err = _error( + srv._methods["vault.add"]( + 1, + { + "kind": "login", + "label": "no origin", + "secret": { + "identifier_type": "email", + "identifier": "user@example.com", + "password": "s3cret-pw-9000", + }, + }, + ) + ) + assert err["code"] == 5095 + assert "origin is required" in err["message"] + assert "s3cret-pw-9000" not in json.dumps(err) + + err = _error(srv._methods["vault.add"](2, {"kind": "login", "label": "x"})) + assert err["code"] == 5095 + assert "secret payload is required" in err["message"] + + err = _error( + srv._methods["vault.add"]( + 3, {"kind": "wat", "label": "x", "secret": {"password": "s3cret-pw-9000"}} + ) + ) + assert err["code"] == 5095 + assert "unknown vault kind" in err["message"] + assert "s3cret-pw-9000" not in json.dumps(err) + + +def test_remove_is_idempotent(home): + item_id = _result(srv._methods["vault.add"](1, dict(_LOGIN_PARAMS)))["id"] + assert _result(srv._methods["vault.remove"](2, {"id": item_id}))["removed"] is True + assert _result(srv._methods["vault.remove"](3, {"id": item_id}))["removed"] is False + assert _result(srv._methods["vault.list"](4, {}))["items"] == [] + + +def test_remove_requires_id(home): + err = _error(srv._methods["vault.remove"](1, {})) + assert err["code"] == 5095 diff --git a/tools/browser_vault_tool.py b/tools/browser_vault_tool.py new file mode 100644 index 0000000000..599fe85960 --- /dev/null +++ b/tools/browser_vault_tool.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""Vault-backed model-blind browser autofill tools. + +Two model-facing tools, gated on the local vault having at least one item +(zero schema cost otherwise, same ``check_fn`` pattern as the Home Assistant +tools): + +- ``browser_vault_list`` → handles + metadata (for logins this includes the + identifier — it is NOT a secret; the agent types it itself). Passwords are + never returned. +- ``browser_vault_fill`` → server-side fill of ONLY the password field of + the CURRENT page's login form from a vault handle. The password is + resolved locally, the page origin must EXACTLY match the item's bound + origin (pre-checked AND re-asserted synchronously inside the fill script), + the field is chosen by the ported login-control classifier, injection runs + exclusively over the supervisor CDP WebSocket (never argv), and the tool + result reports only ``{filled_fields, kind, origin, success}`` — the + password never appears in tool results, logs, or the session DB, and its + exact bytes are registered with the browser-result redaction boundary so + no later browser tool call can echo them back to the model. + +Ported design from Merit-Systems/OpenInstinct (MIT): opaque-handle vault +autofill (kernel-login-autofill.ts / fill_from_vault.ts). +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Availability check +# --------------------------------------------------------------------------- + +def _check_vault_available() -> bool: + """Tools are only in the schema when the local vault has ≥1 item.""" + try: + from agent.vault_store import get_vault_store + + return get_vault_store().has_items() + except Exception: + return False + + +# --------------------------------------------------------------------------- +# JS evaluation plumbing (server-side; results never carry secret values) +# --------------------------------------------------------------------------- + +def _eval_js(task_id: str, expression: str) -> Dict[str, Any]: + """Evaluate NON-SECRET JS on the current page (inspection, origin reads). + + Prefers the supervisor's persistent CDP WebSocket, falls back to the + agent-browser CLI ``eval`` command. Never use this for expressions that + embed secret values — the fallback places the expression in subprocess + argv. Use :func:`_eval_js_secret` for secret-bearing expressions. + """ + try: + from tools.browser_supervisor import SUPERVISOR_REGISTRY + + supervisor = SUPERVISOR_REGISTRY.get(task_id) + if supervisor is not None: + sup = supervisor.evaluate_runtime(expression) + if sup.get("ok"): + return {"success": True, "result": sup.get("result")} + err = str(sup.get("error") or "") + if "supervisor" not in err.lower(): + return {"success": False, "error": err} + except ImportError: + pass + except Exception as exc: # pragma: no cover — defensive + logger.debug("vault fill: supervisor eval unavailable (%s)", exc) + + from tools.browser_tool import _last_session_key + from tools.browser_tool_session import _run_browser_command + + effective = _last_session_key(task_id) + result = _run_browser_command(effective, "eval", [expression]) + if not result.get("success"): + return {"success": False, "error": result.get("error", "eval failed")} + return {"success": True, "result": result.get("data", {}).get("result")} + + +def _eval_js_secret(task_id: str, expression: str) -> Dict[str, Any]: + """Evaluate a SECRET-BEARING JS expression. Supervisor CDP-WS only. + + Fails closed: there is deliberately NO fallback to the agent-browser CLI + ``eval`` path, because that places the expression — and therefore the + credential bytes — in subprocess argv, visible to any process listing. + When no supervisor session is available the caller gets a typed refusal + (``error_type='supervisor_required'``) and nothing is written. + """ + try: + from tools.browser_supervisor import SUPERVISOR_REGISTRY + + supervisor = SUPERVISOR_REGISTRY.get(task_id) + except ImportError: + supervisor = None + except Exception as exc: # pragma: no cover — defensive + logger.debug("vault fill: supervisor registry unavailable (%s)", exc) + supervisor = None + + if supervisor is None: + return { + "success": False, + "error_type": "supervisor_required", + "error": ( + "Vault fill requires the supervised browser session (direct " + "CDP WebSocket). The fallback eval path would place the " + "credential in subprocess argv, so it is never used for " + "secrets. Start the browser through the Hermes-managed " + "session and retry." + ), + } + + sup = supervisor.evaluate_runtime(expression) + if sup.get("ok"): + return {"success": True, "result": sup.get("result")} + return { + "success": False, + "error_type": "supervisor_required" + if "supervisor" in str(sup.get("error") or "").lower() + else "eval_failed", + "error": str(sup.get("error") or "eval failed"), + } + + +def _parse_json_result(raw: Any) -> Any: + if isinstance(raw, str): + try: + return json.loads(raw) + except (json.JSONDecodeError, ValueError): + return raw + return raw + + +def _current_page_origin(task_id: str) -> Optional[str]: + res = _eval_js(task_id, "window.location.href") + if not res.get("success"): + return None + href = str(res.get("result") or "").strip().strip('"').strip("'") + if not href or href == "about:blank": + return None + try: + from agent.vault_store import normalize_origin + + return normalize_origin(href) + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + +def browser_vault_list() -> str: + """List vault items as handles + metadata. Secret values never included. + + Login identifiers (email/username/phone) ARE included — they are + metadata, not secrets, so the agent can type the identifier itself. + """ + from agent.vault_store import get_vault_store + + items = [] + for meta in get_vault_store().list_items(): + entry = { + "handle": meta.id, + "label": meta.label, + "kind": meta.kind, + "origin": meta.origin, + # Phase 1: only login items are fillable. + "available": meta.kind == "login", + } + if meta.identifier: + entry["identifier"] = meta.identifier + entry["identifier_type"] = meta.identifier_type + items.append(entry) + return json.dumps({"success": True, "items": items}, ensure_ascii=False) + + +def browser_vault_fill(handle: str, task_id: Optional[str] = None) -> str: + """Fill the current page's password field from a vault handle. + + Password-only: the identifier is agent-visible metadata (see + browser_vault_list) and is typed by the agent via normal input tools. + The password is resolved server-side and injected via in-page JS over + the supervisor CDP WebSocket; the result reports only counts/metadata. + """ + from agent.redact import register_vault_redaction_value + from agent.vault_login_classifier import ( + LOGIN_CONTROL_INSPECTION_JS, + ClassifiedLoginControl, + LoginControl, + build_fill_js, + classify_login_control, + select_password_fill, + ) + from agent.vault_store import VaultError, get_vault_store, scrub_secret_from_text + + effective_task_id = task_id or "default" + store = get_vault_store() + + meta = store.get_meta(handle) + if meta is None: + return json.dumps( + { + "success": False, + "error": ( + f"No vault item with handle {handle!r}. Use browser_vault_list. " + "To save a credential: run `hermes vault add` in a terminal, or " + "in the desktop app open Settings → Credential Vault." + ), + } + ) + if meta.kind != "login": + return json.dumps( + {"success": False, "error": f"Vault item {handle!r} is kind={meta.kind!r}; only login items can be filled in Phase 1."} + ) + + # ── Origin binding pre-check (cheap early exit; the authoritative check + # runs synchronously inside the fill script itself) ────────────────────── + page_origin = _current_page_origin(effective_task_id) + if not page_origin: + return json.dumps( + {"success": False, "error": "Could not determine the current page origin. Navigate to the login page first."} + ) + if page_origin != meta.origin: + return json.dumps( + { + "success": False, + "error_type": "origin_mismatch", + "error": ( + f"Refused: current page origin ({page_origin}) does not match " + f"the vault item's bound origin ({meta.origin}). Vault fills " + "only run on the exact origin the credential was saved for." + ), + } + ) + + # ── Inspect + classify page controls ──────────────────────────────────── + inspect = _eval_js(effective_task_id, LOGIN_CONTROL_INSPECTION_JS) + if not inspect.get("success"): + return json.dumps( + {"success": False, "error": f"Could not inspect page inputs: {inspect.get('error', 'eval failed')}"} + ) + raw_controls = _parse_json_result(inspect.get("result")) + if isinstance(raw_controls, str): + raw_controls = _parse_json_result(raw_controls) + if not isinstance(raw_controls, list): + return json.dumps({"success": False, "error": "Page input inspection returned no usable controls."}) + + classified: list[ClassifiedLoginControl] = [] + for raw in raw_controls: + if not isinstance(raw, dict): + continue + result = classify_login_control(LoginControl.from_dict(raw)) + if result is not None: + classified.append(result) + if not classified: + return json.dumps({"success": False, "error": "No login form fields were found on the current page."}) + + # ── Resolve secret and fill (secret never enters any logged string) ───── + secret = store.resolve_secret(handle) + password = str(secret.get("password") or "") + fills = select_password_fill(classified, password) + if not fills: + return json.dumps( + {"success": False, "error": "No fillable password field matched (is there a password field on this page?)."} + ) + + # Register the secret bytes with the model-egress redaction boundary + # BEFORE they touch the page: any later browser_* result (including + # browser_cdp Runtime.evaluate reads) that echoes them is scrubbed. + register_vault_redaction_value(password) + + try: + fill_result = _eval_js_secret( + effective_task_id, build_fill_js(fills, expected_origin=str(meta.origin)) + ) + except Exception as exc: + # Strip any secret material from exception text before surfacing. + return json.dumps( + {"success": False, "error": scrub_secret_from_text(str(exc), secret)} + ) + if not fill_result.get("success"): + err = scrub_secret_from_text(str(fill_result.get("error") or "fill failed"), secret) + out = {"success": False, "error": err} + if fill_result.get("error_type"): + out["error_type"] = fill_result["error_type"] + return json.dumps(out) + + parsed = _parse_json_result(fill_result.get("result")) + if isinstance(parsed, str): + parsed = _parse_json_result(parsed) + if isinstance(parsed, dict) and parsed.get("refused") == "origin_changed": + return json.dumps( + { + "success": False, + "error_type": "origin_changed", + "error": ( + "Refused: the page navigated away from the bound origin " + f"({meta.origin}) before the fill could run " + f"(now on {parsed.get('found') or 'unknown'}). " + "Nothing was written." + ), + } + ) + filled = parsed.get("filled", 0) if isinstance(parsed, dict) else 0 + + return json.dumps( + { + "success": bool(filled), + "filled_fields": int(filled), + "kind": meta.kind, + "origin": meta.origin, + } + ) + + +# --------------------------------------------------------------------------- +# Schemas + registration +# --------------------------------------------------------------------------- + +BROWSER_VAULT_LIST_SCHEMA = { + "name": "browser_vault_list", + "description": ( + "List credentials stored in the local encrypted vault as handles " + "with metadata (label, kind, bound origin, and for logins the " + "identifier + identifier_type — identifiers are visible so you can " + "type them yourself with fill_input). Passwords are NEVER returned. " + "Workflow: type the identifier with fill_input, then call " + "browser_vault_fill with the handle to fill the password." + ), + "input_schema": {"type": "object", "properties": {}, "required": []}, +} + +BROWSER_VAULT_FILL_SCHEMA = { + "name": "browser_vault_fill", + "description": ( + "Fill ONLY the password field of the CURRENT browser page's login " + "form from a vault handle (see browser_vault_list). Type the " + "identifier/username yourself first with fill_input (it is visible " + "in the vault metadata), then call this to fill the password. The " + "password is resolved and injected server-side; it never appears in " + "the conversation. Refused unless the page origin exactly matches " + "the credential's bound origin (re-checked atomically at fill time)." + ), + "input_schema": { + "type": "object", + "properties": { + "handle": { + "type": "string", + "description": "Vault item handle from browser_vault_list (e.g. vault_ab12cd34ef56)", + } + }, + "required": ["handle"], + }, +} + + +def _handle_vault_list(args: Dict[str, Any], **kwargs) -> str: + return browser_vault_list() + + +def _handle_vault_fill(args: Dict[str, Any], **kwargs) -> str: + return browser_vault_fill( + handle=str(args.get("handle") or ""), task_id=kwargs.get("task_id") + ) + + +from tools.registry import registry # noqa: E402 + +registry.register( + name="browser_vault_list", + toolset="browser", + schema=BROWSER_VAULT_LIST_SCHEMA, + handler=_handle_vault_list, + check_fn=_check_vault_available, + emoji="🔐", +) + +registry.register( + name="browser_vault_fill", + toolset="browser", + schema=BROWSER_VAULT_FILL_SCHEMA, + handler=_handle_vault_fill, + check_fn=_check_vault_available, + emoji="🔐", +) diff --git a/toolsets.py b/toolsets.py index f8b7c44d30..f8e98a53bf 100644 --- a/toolsets.py +++ b/toolsets.py @@ -18,6 +18,7 @@ _HERMES_CORE_TOOLS = [ "browser_type", "browser_scroll", "browser_back", "browser_press", "browser_get_images", "browser_vision", "browser_console", "browser_cdp", "browser_dialog", + "browser_vault_list", "browser_vault_fill", # gated on a non-empty vault via check_fn "browser_exec", # replaces the other browser tools when browser.backend is "browser-use" "text_to_speech", "todo_list", "memory", diff --git a/tui_gateway/methods_vault.py b/tui_gateway/methods_vault.py new file mode 100644 index 0000000000..96ef465c72 --- /dev/null +++ b/tui_gateway/methods_vault.py @@ -0,0 +1,90 @@ +"""Credential-vault JSON-RPC handlers — the Desktop's door to the local vault. + +The Desktop's Settings → Credential Vault panel manages the encrypted, +model-blind vault (``agent/vault_store.py``) over the same localhost WS +JSON-RPC channel every other Settings surface uses. Contracts: + +- ``vault.list`` → metadata only ({id, kind, label, origin, created_at, + and for logins identifier/identifier_type — identifiers are visible + metadata by design}); passwords NEVER appear in any response. +- ``vault.add`` → validates via ``VaultStore.add_item``; the secret + payload arrives over the local RPC channel, goes straight into the + encrypted store, and is never logged. Error strings are defensively + scrubbed with ``scrub_secret_from_text`` before they leave the handler. +- ``vault.remove`` → {removed: bool}. + +Handlers are rebound onto server.py's globals at install time (see +method_ctx.py) and may reference server module globals (``_ok``, ``_err``). +""" + +from .method_ctx import HandlerRegistry + +_registry = HandlerRegistry() +method = _registry.method + +# JSON-RPC error code 5095 = vault failure (validation + store errors). +# Kept as a literal inside handler bodies: handlers are rebound onto +# server.py's globals, so module-level constants are not reachable there. + + +@method("vault.list") +def _(rid, params: dict) -> dict: + """Metadata-only listing of vault items. Secret values are never included.""" + try: + from agent.vault_store import get_vault_store + + items = [meta.to_dict() for meta in get_vault_store().list_items()] + return _ok(rid, {"items": items}) + except Exception as e: + return _err(rid, 5095, str(e)) + + +@method("vault.add") +def _(rid, params: dict) -> dict: + """Add a vault item. ``secret`` values go straight into the encrypted store. + + Params: ``kind`` (login|payment|address), ``label``, ``origin?``, + ``secret`` (dict). Result: ``{id}`` — metadata only. Exception text is + scrubbed of secret values before it can reach a response or a log line. + """ + from agent.vault_store import ( + VaultError, + get_vault_store, + scrub_secret_from_text, + ) + + secret = params.get("secret") + if not isinstance(secret, dict) or not secret: + return _err(rid, 5095, "secret payload is required") + try: + meta = get_vault_store().add_item( + kind=str(params.get("kind") or ""), + label=str(params.get("label") or ""), + origin=(str(params.get("origin")) if params.get("origin") else None), + secret=secret, + ) + return _ok(rid, {"id": meta.id}) + except VaultError as e: + # VaultError messages are metadata-safe by contract, but scrub anyway. + return _err(rid, 5095, scrub_secret_from_text(str(e), secret)) + except Exception as e: + return _err(rid, 5095, scrub_secret_from_text(str(e), secret)) + + +@method("vault.remove") +def _(rid, params: dict) -> dict: + """Remove a vault item by id. Result: ``{removed: bool}``.""" + try: + from agent.vault_store import get_vault_store + + item_id = str(params.get("id") or "") + if not item_id: + return _err(rid, 5095, "id is required") + return _ok(rid, {"removed": get_vault_store().remove_item(item_id)}) + except Exception as e: + return _err(rid, 5095, str(e)) + + +def register(server) -> None: + """Bind this module's handlers onto ``server``'s globals and registry.""" + _registry.install(server) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e3c486b48e..638734d0fb 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3213,7 +3213,8 @@ from . import ( # noqa: E402 methods_profiles as _methods_profiles, methods_prompt as _methods_prompt, methods_session as _methods_session, methods_tools as _methods_tools, prompt_turn as _prompt_turn, billing_view as _billing_view, methods_projects as _methods_projects, methods_session_foreign as _methods_session_foreign, - methods_session_control as _methods_session_control, methods_subagents as _methods_subagents) + methods_session_control as _methods_session_control, methods_subagents as _methods_subagents, + methods_vault as _methods_vault) for _m in ( _session_transports, _session_reaper, _session_lifecycle, _session_workdir, _compute_host_bridge, _model_switch, @@ -3223,6 +3224,6 @@ for _m in ( _methods_browser_control, _methods_session, _methods_prompt, _methods_config, _methods_config_set, _methods_complete, _methods_tools, _methods_profiles, _methods_images, _methods_bot_relay, _prompt_turn, _billing_view, _methods_projects, _methods_session_foreign, - _methods_session_control, _methods_subagents): + _methods_session_control, _methods_subagents, _methods_vault): _m.register(sys.modules[__name__]) del _m diff --git a/website/docs/user-guide/features/credential-vault.md b/website/docs/user-guide/features/credential-vault.md new file mode 100644 index 0000000000..eaa168f22a --- /dev/null +++ b/website/docs/user-guide/features/credential-vault.md @@ -0,0 +1,120 @@ +# Credential Vault (Password-Blind Autofill) + +Store site logins in a locally encrypted vault and let the agent log into +websites **without ever seeing the password**. The login identifier +(email/username/phone) is ordinary metadata the agent can see and type +itself; only the password is vault-secret — it is resolved server-side and +injected directly into the page. + +## How it works + +1. You add a credential with `hermes vault add` (interactive; the + identifier is prompted normally, the password is read with a hidden + prompt and never echoed or passed on the command line). +2. The password is encrypted at rest under `~/.hermes/vault/` (Fernet key + + vault file, both `0600`) and the item is bound to an exact **origin** + (`scheme://host[:port]`). The identifier is stored as item metadata. +3. When the vault has at least one item, two browser tools appear in the + agent's toolset (they add zero schema cost otherwise): + - `browser_vault_list` — handles + metadata, including the login + identifier. Passwords are never returned. + - `browser_vault_fill(handle)` — fills **only the password field** of + the current page's login form. +4. The agent types the identifier itself with its normal input tools, then + calls `browser_vault_fill`. Hermes checks that the **current page origin + exactly matches** the credential's bound origin — once up front, and + again synchronously inside the injected fill script immediately before + the write (so a page that navigates mid-flight gets a refusal and zero + bytes written). It classifies visible login fields (ported from + OpenInstinct's login-control classifier — autocomplete tokens win, + `new-password` / `one-time-code` fields are hard-excluded), picks the + single best current-password field, injects the value over the + supervised browser session's direct CDP WebSocket, and returns only + `{filled_fields, kind, origin, success}`. + +The password never appears in tool results, logs, or the session database. +Its exact bytes are additionally registered with the browser-result +redaction boundary, so even a later `browser_cdp` read that manages to echo +the page's DOM cannot return them to the model. + +## CLI + +```bash +# Add a login (interactive wizard; password is hidden) +hermes vault add + +# List items — identifiers and origins shown, passwords never +hermes vault list + +# Remove an item by handle +hermes vault rm vault_ab12cd34ef56 +``` + +Item kinds: `login`, `payment`, and `address` are all stored (`payment` and +`address` payloads remain fully secret); Phase 1 browser fill supports +`login` items only. + +## Desktop app + +Desktop users can manage the vault without a terminal: open +**Settings → Credential Vault** (right next to the Browser section). The +panel lists saved items — label, kind, login identifier, origin, and +creation date; passwords are never displayed — and lets you add or delete +credentials. The +Add dialog adapts to the selected kind (login / payment card / address), +masks secret fields, and submits them straight into the encrypted store +over the local gateway connection. + +The panel is deep-linkable: opening + +```text +hermes://open/settings?tab=vault&kind=login&label=github&origin=https://github.com +``` + +launches the app on the vault panel with the Add dialog pre-filled from +the query parameters (metadata only — a secret can never travel in a +link). When a fill request fails because no matching item exists, the +agent's error message points at both `hermes vault add` and this panel. + +## Example agent flow + +``` +User: log into example.com and check my dashboard +Agent: browser_navigate("https://example.com/login") +Agent: browser_vault_list() → [{handle: "vault_…", label: "Example", identifier: "me@example.com", origin: "https://example.com"}] +Agent: fill_input(, "me@example.com") +Agent: browser_vault_fill("vault_…") → {"success": true, "filled_fields": 1, "kind": "login", "origin": "https://example.com"} +Agent: browser_click() +``` + +## Security properties + +- **Password-blind:** the agent never sees password values — only handles, + labels, identifiers, and origins. +- **Origin-bound at use time:** fills are refused unless the page origin + exactly matches (scheme + host + port) the origin the credential was + saved for — asserted both before the fill and atomically inside the fill + script itself, so a mid-flight navigation (including cross-origin) writes + nothing. +- **No argv exposure:** the secret-bearing injection runs exclusively over + the supervised browser session's CDP WebSocket. If that session is not + available, the fill refuses rather than falling back to a subprocess + path that would place the password in argv. +- **Redaction-backed egress boundary:** filled password bytes are + registered with the browser tool-result redactor for the life of the + process; every `browser_*` result (including raw `browser_cdp` output) + is scrubbed against them. +- **No signup/OTP capture:** fields marked `autocomplete="new-password"` + or `one-time-code`, and fields labeled *new/confirm/create/repeat + password*, are never filled. +- **Encrypted at rest:** vault file and key are created `0600` in your + Hermes home; nothing is sent to any server. + +## Notes + +- No configuration is needed; the tools activate automatically once the + vault has an item. +- The fill targets the single best current-password field (autocomplete + token beats type heuristics; ties break in DOM order). +- Design ported from Merit-Systems/OpenInstinct's opaque-handle vault + autofill (MIT). diff --git a/website/sidebars.ts b/website/sidebars.ts index 17d165a6b0..34f3693fe6 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -119,6 +119,7 @@ const sidebars: SidebarsConfig = { 'user-guide/features/web-search', 'user-guide/features/x-search', 'user-guide/features/browser', + 'user-guide/features/credential-vault', 'user-guide/features/computer-use', 'user-guide/features/vision', 'user-guide/features/image-generation',