Files
hermes-agent/tui_gateway/methods_vault.py
teknium1 0a44c2acd3 refactor(vault): trim the malformed-state coverage and drop the load_config shape guard
load_config() always returns a dict, so the isinstance re-init was
defense-in-depth. Coverage folded to one invariant per bug and surface:
every VaultStore reader raises VaultError on a corrupt file, `hermes
vault` prints a clean error line, and both the CLI sources toggle and
the TUI vault.source.set RPC persist the opt-out over a scalar section.
2026-09-20 10:21:52 -07:00

186 lines
7.6 KiB
Python

"""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}.
- ``vault.sources`` / ``vault.source.set`` → external password-manager status and enable toggle.
- ``vault.unlock`` / ``vault.lock`` → per-session unlock of a manager from Settings; the master
password is consumed by the manager CLI through its non-interactive channel and never stored
or logged.
Every handler honours ``params.profile`` (app-global remote mode serves several profiles from one
backend): the requested profile's HERMES_HOME and secret scope are bound around the body, so the
vault file, manager config and manager tokens all resolve to that profile.
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
# server.py's ``@_profile_scoped`` (applied at install): the one scoping path every RPC uses, so the
# launch profile (no ``params.profile``) keeps its secret scope bound once the process multiplexes
# instead of its manager-token reads raising ``UnscopedSecretError``.
_profile_scoped = _registry.profile_scoped
# 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")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Metadata-only listing across every enabled backend (local + unlocked password managers).
Each item carries ``backend``; locked managers contribute nothing (see vault.sources)."""
try:
from agent.vault_backends import enabled_backends
items = []
for backend in enabled_backends():
if backend.needs_unlock and not backend.is_unlocked():
continue
items.extend({**meta.to_dict(), "backend": backend.name} for meta in backend.list_items())
return _ok(rid, {"items": items})
except Exception as e:
return _err(rid, 5095, str(e))
@method("vault.sources")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Status of every login source: {name, display_name, enabled, needs_unlock, unlocked, installed}."""
from agent.vault_backends import enabled_backends
from agent.vault_backends.base import external_backend_classes, is_installed
enabled = {b.name: b for b in enabled_backends()}
rows = [{"name": "local", "display_name": "Hermes vault", "enabled": True, "needs_unlock": False,
"unlocked": True, "installed": True}]
for cls in external_backend_classes():
live = enabled.get(cls.name)
rows.append({"name": cls.name, "display_name": cls.display_name, "enabled": live is not None,
"needs_unlock": True, "unlocked": bool(live and live.is_unlocked()),
"installed": is_installed(cls.name)})
return _ok(rid, {"sources": rows})
@method("vault.source.set")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Enable/disable an external manager: writes ``vault.<name>.enabled`` and locks it when disabling."""
from agent.vault_backends.base import external_backend_classes
from agent.vault_backends.unlock import lock
from hermes_cli.config import _ensure_dict, load_config, save_config
name = str(params.get("name") or "")
if name not in {cls.name for cls in external_backend_classes()}:
return _err(rid, 5095, f"unknown vault source: {name}")
enabled = bool(params.get("enabled"))
cfg = load_config()
section = _ensure_dict(_ensure_dict(cfg, "vault"), name)
if enabled:
section.pop("enabled", None) # detected managers are on by default; this removes the opt-out
else:
section["enabled"] = False
if not enabled:
lock(name)
save_config(cfg)
return _ok(rid, {"name": name, "enabled": enabled})
@method("vault.unlock")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Unlock a manager with the master password typed in the Settings dialog (consumed by the CLI on stdin)."""
from agent.vault_backends import enabled_backends
name = str(params.get("name") or "")
password = str(params.get("password") or "")
backend = next((b for b in enabled_backends() if b.name == name and b.needs_unlock), None)
if backend is None:
return _err(rid, 5095, f"{name} is not an enabled password manager")
if not password:
return _err(rid, 5095, "master password is required")
try:
backend.unlock(password) # type: ignore[attr-defined]
except Exception as e:
return _err(rid, 5095, str(e).replace(password, "[REDACTED]"))
finally:
del password
return _ok(rid, {"name": name, "unlocked": True})
@method("vault.lock")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Forget a manager's session token (or every one when ``name`` is omitted)."""
from agent.vault_backends.unlock import lock
name = params.get("name")
lock(str(name) if name else None)
return _ok(rid, {"locked": True})
@method("vault.add")
@_profile_scoped
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")
@_profile_scoped
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)