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
This commit is contained in:
Teknium
2026-09-09 02:03:17 -07:00
parent 94f77dfa0d
commit cde0ad0edd
27 changed files with 3033 additions and 11 deletions
+4
View File
@@ -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)."),
)
+46 -1
View File
@@ -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
+209
View File
@@ -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": <actual origin>}`` — 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"
"})()"
)
+320
View File
@@ -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 ``<HERMES_HOME>/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 ``<HERMES_HOME>/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
+24 -2
View File
@@ -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
<NotificationsSettings />
) : activeView === 'billing' ? (
<BillingSettings />
) : activeView === 'vault' ? (
<VaultSettings />
) : (
<SessionsSettings />
)
+1
View File
@@ -14,6 +14,7 @@ export type SettingsView =
| 'notifications'
| 'providers'
| 'sessions'
| 'vault'
| `config:${string}`
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
@@ -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(
<MemoryRouter initialEntries={[route]}>
<QueryClientProvider client={queryClient}>
<VaultSettings />
</QueryClientProvider>
</MemoryRouter>
)
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' }))
})
})
@@ -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<string, string> {
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<string, string> = {
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<VaultForm>(EMPTY_FORM)
const [formError, setFormError] = useState<null | string>(null)
const [pendingDelete, setPendingDelete] = useState<null | VaultItem>(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<string, string> }) =>
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 (
<SettingsContent>
<SectionHeading
aside={
<Button className="gap-1.5" onClick={() => openAdd()} size="sm" type="button" variant="outline">
<Plus className="size-3.5" />
{v.add}
</Button>
}
icon={ShieldLock}
meta={items.length > 0 ? v.count(items.length) : undefined}
title={v.title}
/>
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{v.blurb}
</p>
{!isPending && items.length === 0 && <EmptyState description={v.emptyDesc} title={v.empty} />}
{items.map(item => (
<ListRow
action={
<Button
aria-label={v.deleteAction}
className="text-(--ui-text-tertiary) hover:text-destructive"
onClick={() => setPendingDelete(item)}
size="icon-sm"
type="button"
variant="ghost"
>
<Trash2 className="size-3.5" />
</Button>
}
description={
<span className="flex flex-wrap items-center gap-2">
{item.identifier && <span className="truncate">{v.identifierShown(item.identifier)}</span>}
{item.origin && <span className="truncate">{item.origin}</span>}
<span>{v.createdOn(formatCreated(item.created_at))}</span>
</span>
}
key={item.id}
title={
<span className="flex items-center gap-2">
<span className="truncate">{item.label}</span>
<Pill tone={item.kind === 'login' ? 'primary' : 'muted'}>{kindLabel(item.kind)}</Pill>
</span>
}
/>
))}
{/* Add dialog */}
<Dialog onOpenChange={open => !open && closeAdd()} open={addOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{v.addTitle}</DialogTitle>
<DialogDescription>{v.addDescription}</DialogDescription>
</DialogHeader>
<form
className="grid gap-4"
onSubmit={e => {
e.preventDefault()
submitAdd()
}}
>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="vault-kind" label={v.kindField}>
<Select
onValueChange={value => setForm(f => ({ ...f, kind: value as VaultKind }))}
value={form.kind}
>
<SelectTrigger className={CONTROL_TEXT} id="vault-kind">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VAULT_KINDS.map(kind => (
<SelectItem key={kind} value={kind}>
{v.kinds[kind]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field htmlFor="vault-label" label={v.labelField}>
<Input
autoFocus
id="vault-label"
onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
placeholder={v.labelPlaceholder}
value={form.label}
/>
</Field>
</div>
{form.kind === 'login' && (
<>
<Field htmlFor="vault-origin" label={v.originField}>
<Input
id="vault-origin"
inputMode="url"
onChange={e => setForm(f => ({ ...f, origin: e.target.value }))}
placeholder={v.originPlaceholder}
value={form.origin}
/>
</Field>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="vault-id-type" label={v.identifierTypeField}>
<Select
onValueChange={value => setForm(f => ({ ...f, identifierType: value as IdentifierType }))}
value={form.identifierType}
>
<SelectTrigger className={CONTROL_TEXT} id="vault-id-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{IDENTIFIER_TYPES.map(type => (
<SelectItem key={type} value={type}>
{v.identifierTypes[type]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field htmlFor="vault-identifier" label={v.identifierField}>
<Input
autoComplete="off"
id="vault-identifier"
onChange={e => setForm(f => ({ ...f, identifier: e.target.value }))}
value={form.identifier}
/>
</Field>
</div>
<Field htmlFor="vault-password" label={v.passwordField}>
<Input
autoComplete="new-password"
id="vault-password"
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
type="password"
value={form.password}
/>
</Field>
</>
)}
{form.kind === 'payment' && (
<>
<Field htmlFor="vault-card-number" label={v.cardNumberField}>
<Input
autoComplete="off"
id="vault-card-number"
inputMode="numeric"
onChange={e => setForm(f => ({ ...f, cardNumber: e.target.value }))}
type="password"
value={form.cardNumber}
/>
</Field>
<Field htmlFor="vault-card-name" label={v.cardNameField}>
<Input
autoComplete="off"
id="vault-card-name"
onChange={e => setForm(f => ({ ...f, cardName: e.target.value }))}
value={form.cardName}
/>
</Field>
<div className="grid items-start gap-4 sm:grid-cols-4">
<Field htmlFor="vault-exp-month" label={v.expMonthField}>
<Input
id="vault-exp-month"
inputMode="numeric"
maxLength={2}
onChange={e => setForm(f => ({ ...f, expMonth: e.target.value }))}
placeholder="MM"
value={form.expMonth}
/>
</Field>
<Field htmlFor="vault-exp-year" label={v.expYearField}>
<Input
id="vault-exp-year"
inputMode="numeric"
maxLength={4}
onChange={e => setForm(f => ({ ...f, expYear: e.target.value }))}
placeholder="YYYY"
value={form.expYear}
/>
</Field>
<Field htmlFor="vault-cvc" label={v.cvcField}>
<Input
autoComplete="off"
id="vault-cvc"
inputMode="numeric"
maxLength={4}
onChange={e => setForm(f => ({ ...f, cvc: e.target.value }))}
type="password"
value={form.cvc}
/>
</Field>
<Field htmlFor="vault-postal" label={v.postalField}>
<Input
id="vault-postal"
onChange={e => setForm(f => ({ ...f, postal: e.target.value }))}
value={form.postal}
/>
</Field>
</div>
</>
)}
{form.kind === 'address' && (
<>
<Field htmlFor="vault-line1" label={v.addressLine1Field}>
<Input
id="vault-line1"
onChange={e => setForm(f => ({ ...f, line1: e.target.value }))}
value={form.line1}
/>
</Field>
<Field htmlFor="vault-line2" label={v.addressLine2Field} optional optionalLabel={v.optional}>
<Input
id="vault-line2"
onChange={e => setForm(f => ({ ...f, line2: e.target.value }))}
value={form.line2}
/>
</Field>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="vault-city" label={v.cityField}>
<Input
id="vault-city"
onChange={e => setForm(f => ({ ...f, city: e.target.value }))}
value={form.city}
/>
</Field>
<Field htmlFor="vault-state" label={v.stateField} optional optionalLabel={v.optional}>
<Input
id="vault-state"
onChange={e => setForm(f => ({ ...f, state: e.target.value }))}
value={form.state}
/>
</Field>
</div>
<div className="grid items-start gap-4 sm:grid-cols-2">
<Field htmlFor="vault-address-postal" label={v.postalField}>
<Input
id="vault-address-postal"
onChange={e => setForm(f => ({ ...f, postal: e.target.value }))}
value={form.postal}
/>
</Field>
<Field htmlFor="vault-country" label={v.countryField}>
<Input
id="vault-country"
onChange={e => setForm(f => ({ ...f, country: e.target.value }))}
value={form.country}
/>
</Field>
</div>
</>
)}
{formError && <p className="text-xs text-destructive">{formError}</p>}
<DialogFooter>
<Button onClick={closeAdd} size="sm" type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button disabled={addMutation.isPending} size="sm" type="submit">
{addMutation.isPending ? v.adding : v.addConfirm}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<ConfirmDialog
confirmLabel={v.deleteConfirm}
description={pendingDelete ? v.deleteDescription(pendingDelete.label) : undefined}
destructive
onClose={() => setPendingDelete(null)}
onConfirm={async () => {
if (pendingDelete) {
await deleteItem(pendingDelete)
}
}}
open={pendingDelete !== null}
title={v.deleteTitle}
/>
</SettingsContent>
)
}
+49 -1
View File
@@ -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: 'إضافات سطح المكتب',
+49 -1
View File
@@ -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.',
+49 -1
View File
@@ -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: '通知',
+46
View File
@@ -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
+47 -1
View File
@@ -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: '通知',
+47 -1
View File
@@ -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: '桌面插件',
+2
View File
@@ -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,
+4 -1
View File
@@ -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
+3
View File
@@ -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)
+21
View File
@@ -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)
+174
View File
@@ -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)
+515
View File
@@ -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
+114
View File
@@ -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
+392
View File
@@ -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="🔐",
)
+1
View File
@@ -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",
+90
View File
@@ -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)
+3 -2
View File
@@ -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
@@ -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(<username field>, "me@example.com")
Agent: browser_vault_fill("vault_…") → {"success": true, "filled_fields": 1, "kind": "login", "origin": "https://example.com"}
Agent: browser_click(<submit>)
```
## 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).
+1
View File
@@ -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',