Files
hermes-agent/website/docs/user-guide/features/credential-vault.md
T
Teknium c8998c3957 feat(vault): fill payment cards and addresses at checkout, cards behind a confirm prompt
payment and address items could be stored (CLI wizard, Desktop dialog) but nothing could
fill them: a dead surface holding real card numbers. browser_vault_fill now handles all
three kinds through the same origin-bound, supervisor-only, redacted path:

- classify_checkout_control / select_checkout_fills map WHATWG autocomplete tokens
  (cc-number, cc-exp[-month|-year], cc-csc, address-line1/2, address-level1/2, postal-code,
  country-name) with label/name heuristics as backup; a combined "MM/YY" control gets
  exp_month+exp_year and suppresses the split fills; inspection now covers <select>
  (country, state, expiry month) and the fill script picks an option by value or text.
- Every payment fill goes through request_elicitation_consent (gateway button round-trip
  or CLI panel) before a byte is written; declined → payment_declined, headless sessions
  are refused. A prompt injection that reaches a checkout can ask, not spend. Card values
  join the redaction registry like passwords; the result lists targeted field tokens only.
- Origin is now required for every kind (CLI wizard asks; Desktop dialog always shows the
  field) because a card without a bound origin is unfillable.
- The tool descriptions, docs and CLI copy drop "Phase 1 / login only".

Live (evals/vault_fill_live_e2e.py, real browser_exec + packaged Chromium): decline writes
nothing; accept fills card/expiry/CVC on the /checkout tab, leaves the email box and the
country <select> untouched, and neither the card number nor the CVC appears in any result.

browser_vault_tool also: focuses the tab on the bound origin holding the right form before the
origin pre-check (focus_page from the previous commit); tool descriptions say "the browser's
input tool" (rewritten per session by model_tools); _check_vault_available is registered
uncached because its answer is per profile (vault dir + config) and the probe is a file stat.
2026-09-10 10:35:07 -07:00

12 KiB

Credential Vault (Password-Blind Autofill)

Let the agent log into websites without ever seeing the password, using logins from a locally encrypted vault or from your password manager (1Password, Bitwarden). 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 does not appear in the fill's tool result, logs, or the session database, and its exact bytes are registered with the browser-result redaction boundary so a later browser_* read that echoes the page's DOM is scrubbed. See What this does and does not guarantee below for the limits.

CLI

# 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 (the password is the secret; the identifier is visible metadata the agent types itself), payment (card number, cardholder, expiry, CVC, billing postal code) and address. Every kind is bound to the site origin it may be filled on.

Paying and filling addresses

A payment or address item fills the matching checkout fields the same way a login fills the password: the agent calls browser_vault_fill with the handle, Hermes classifies the page's controls (autocomplete tokens first, then label/name heuristics: "Card number", "Expiry (MM/YY)", "CVC", "ZIP", country and state <select>s) and writes the values over the supervised CDP socket. The result names the targeted fields (cc-number, cc-exp, cc-csc, address-line1, …) but never a value; card values are registered with the redactor like passwords.

Every payment fill asks you first. Before a card is written you get the same approval prompt as a dangerous command (button in Desktop/TUI/chat platforms, panel in the CLI). Declining returns payment_declined to the agent and writes nothing; headless sessions (cron, webhook, API) cannot confirm and are refused. This is the guard against a prompt injection that reaches a checkout page: it can ask, it cannot spend. Address fills need no confirmation (an address is not a spending instrument).

Password managers (1Password, Bitwarden)

You don't have to copy logins into the Hermes vault. Enable a password manager and its website logins become fillable handles alongside the local ones (op:… for 1Password, bw:… for Bitwarden). The password is fetched from the manager's CLI at fill time only and follows the same server-side injection, origin binding, and redaction as a local item.

hermes vault sources                        # status of each manager
hermes vault sources --enable onepassword   # needs the `op` CLI on PATH
hermes vault sources --enable bitwarden     # needs the `bw` CLI; run `bw login` once first

or Desktop → Settings → Credential Vault → Password managers (toggle, Unlock, Lock).

Unlocking is per session

A manager starts locked. The first time the agent needs one of its logins it asks you to unlock: a masked master-password prompt appears in the CLI, TUI, or Desktop chat (or you can unlock ahead of time from Settings). Hermes hands the master password to the manager CLI through its non-interactive channel (op signin reads stdin; bw unlock --passwordenv reads a variable set only in the child process) — never as a command-line argument, never in Hermes' own environment — and keeps only the resulting session token in memory, scoped to the current profile. The token expires after 30 minutes idle, when you press Lock, or when the chat session that unlocked it ends (other sessions in the same profile keep their own unlocks). A Lock pressed while an unlock is still in flight wins. The agent never sees the master password, the token, or any password.

browser_vault_list reports a locked manager under locked, and browser_vault_unlock(backend) triggers the prompt explicitly.

Headless sessions never prompt

Cron jobs, webhooks, the API server, and hermes chat -q have nobody to answer a prompt, so a locked manager is reported as unavailable_in_this_session and fills refuse — the same posture command approvals take there. Unlock from an interactive session or the Desktop app first (the token is per process, so a running gateway that you unlock from a chat keeps serving its own cron jobs), or give 1Password a service-account token (OP_SERVICE_ACCOUNT_TOKEN) to skip the prompt entirely. The local vault needs no unlock and keeps working everywhere.

vault:
  onepassword:
    enabled: true
    account: ""            # `op --account` shorthand; empty = default
    service_account_token_env: OP_SERVICE_ACCOUNT_TOKEN
  bitwarden:
    enabled: true

Bitwarden here means the Password Manager (bw) — website logins — not the Secrets Manager (bws) that the secrets feature uses for API keys.

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

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()          → {items: [{handle: "op:…", backend: "onepassword", label: "Example", identifier: "me@example.com", origin: "https://example.com"}]}
       (or, if 1Password is still locked: {items: [], locked: [{backend: "onepassword", unlock: "browser_vault_unlock"}]} → the agent calls browser_vault_unlock and you get a masked prompt)
Agent: <types "me@example.com" into the username field with the browser's input tool>
Agent: browser_vault_fill("op:…")    → {"success": true, "filled_fields": 1, "backend": "onepassword", "kind": "login", "origin": "https://example.com"}
Agent: browser_click(<submit>)

The same flow works on the default Browser Use backend (browser_exec): the supervisor attaches to the browser browser_exec drives, and the fill picks the open tab on the item's origin that actually holds the form, so the agent can keep several tabs open. On a checkout:

Agent: browser_vault_list()          → {items: [{handle: "vault_…", kind: "payment", label: "Visa", origin: "https://shop.example"}]}
Agent: browser_vault_fill("vault_…") → you see "Fill payment card 'Visa' on https://shop.example — approve?"
                                     → {"success": true, "filled_fields": 3, "kind": "payment", "fields": ["cc-csc", "cc-exp", "cc-number"]}

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 and card bytes are registered with the browser tool-result redactor (per profile, most recent 64 values); every browser_* result (including raw browser_cdp output) is scrubbed against them.
  • Payment needs a human: a card is never written without an approval prompt answered in the session; headless sessions cannot fill cards.
  • Multi-process safe: the local vault file is written under a cross-process lock with fsync, so a Desktop gateway, a CLI hermes vault add and a TUI worker cannot drop each other's items.
  • 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.
  • Master password never stored: for 1Password/Bitwarden the master password is consumed by the manager CLI and dropped; only the session token is held, in memory, per profile, with an idle timeout. Headless sessions can't prompt and see the manager as locked.

What this does and does not guarantee

The vault keeps passwords out of the model's normal path: list/fill results carry metadata only, the fill runs over the supervised CDP socket, and every browser tool result is scrubbed for the exact filled bytes (including the CR/LF-normalized form a text input stores, and JSON object keys). This is accidental-disclosure protection, not an execution sandbox: a session that also has arbitrary page JavaScript (browser_cdp Runtime.evaluate) or host code execution could in principle transform a filled value (for example base64-encode it) into a string the redactor does not recognize. If that matters for a credential, restrict the session's toolset (drop browser_cdp/terminal/execute_code) or use a dedicated low-privilege account for agent logins. Treat the filled credential as exposed to the same trust boundary as the browser session itself.

Notes

  • No configuration is needed for the local vault; the tools activate automatically once it has an item or a password manager is enabled.
  • 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).