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:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
"})()"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 />
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: 'إضافات سطح المكتب',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: '通知',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '通知',
|
||||
|
||||
@@ -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: '桌面插件',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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="🔐",
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user