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.
This commit is contained in:
Teknium
2026-09-09 10:51:32 -07:00
parent 27ca97e469
commit c8998c3957
15 changed files with 489 additions and 131 deletions
+5
View File
@@ -48,6 +48,11 @@ class LoginBackend(ABC):
def resolve_password(self, handle: str) -> str:
"""Server-side only; raises ``UnlockRequired`` when locked."""
def resolve_secret(self, handle: str) -> Dict[str, str]:
"""Full payload of a payment/address item (server-side only). External managers list only
logins, so the base returns the password-only shape."""
return {"password": self.resolve_password(handle)}
def run_with_stdin_secret(argv: Sequence[str], *, env: Dict[str, str], secret: str, timeout: float,
label: str) -> subprocess.CompletedProcess:
+4 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import List, Optional
from typing import Dict, List, Optional
from agent.vault_backends.base import LoginBackend
from agent.vault_store import VaultItemMeta
@@ -28,3 +28,6 @@ class LocalLoginBackend(LoginBackend):
def resolve_password(self, handle: str) -> str:
return str(_store().resolve_secret(handle).get("password") or "")
def resolve_secret(self, handle: str) -> Dict[str, str]:
return {k: str(v) for k, v in _store().resolve_secret(handle).items()}
+106 -40
View File
@@ -1,4 +1,4 @@
"""Login-form control classifier for vault autofill.
"""Login / checkout 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
@@ -26,6 +26,27 @@ from typing import Any, Dict, List, Optional
LOGIN_AUTOFILL_TOKENS = ("username", "email", "tel", "current-password")
# Payment / address autocomplete tokens (WHATWG) the checkout fill targets. ``cc-exp`` (combined
# MM/YY) is derived at fill time from exp_month + exp_year. Field-name/label heuristics below back
# up sites that omit autocomplete attributes.
PAYMENT_AUTOFILL_TOKENS = ("cc-number", "cc-name", "cc-exp", "cc-exp-month", "cc-exp-year", "cc-csc")
ADDRESS_AUTOFILL_TOKENS = ("address-line1", "address-line2", "address-level2", "address-level1",
"postal-code", "country-name", "country")
_CHECKOUT_HEURISTICS = (
(re.compile(r"\b(?:card\s*number|cardnumber|ccnumber|cc\s*num|pan)\b"), "cc-number"),
(re.compile(r"\b(?:name\s*on\s*card|cardholder|cc\s*name|ccname)\b"), "cc-name"),
(re.compile(r"\b(?:cvc|cvv|csc|security\s*code|card\s*code)\b"), "cc-csc"),
(re.compile(r"\b(?:exp(?:iry|iration)?\s*month|exp\s*mm|ccmonth)\b"), "cc-exp-month"),
(re.compile(r"\b(?:exp(?:iry|iration)?\s*year|exp\s*yy(?:yy)?|ccyear)\b"), "cc-exp-year"),
(re.compile(r"\b(?:exp(?:iry|iration)?(?:\s*date)?|mm\s*yy|valid\s*thru)\b"), "cc-exp"),
(re.compile(r"\b(?:address\s*(?:line\s*)?2|apt|suite|unit)\b"), "address-line2"),
(re.compile(r"\b(?:address(?:\s*line\s*1)?|street)\b"), "address-line1"),
(re.compile(r"\b(?:city|town|locality)\b"), "address-level2"),
(re.compile(r"\b(?:state|province|region|county)\b"), "address-level1"),
(re.compile(r"\b(?:zip|postal|postcode)\b"), "postal-code"),
(re.compile(r"\b(?:country)\b"), "country-name"),
)
_EXCLUDED_AUTOCOMPLETE = {"new-password", "one-time-code"}
_RE_EXCLUDED_PASSWORD = re.compile(r"\b(?:new|confirm|create|repeat)\s*password\b")
@@ -131,6 +152,44 @@ def select_password_fill(
]
def classify_checkout_control(control: LoginControl) -> Optional[ClassifiedLoginControl]:
"""Classify one control as a payment/address fill target (autocomplete token exact match 100,
label/name heuristic 70), or None. Password/email inputs are never checkout targets."""
tokens = [t for t in control.autocomplete.lower().split() if t]
for token in PAYMENT_AUTOFILL_TOKENS + ADDRESS_AUTOFILL_TOKENS:
if token in tokens:
return ClassifiedLoginControl(control, 100, "country-name" if token == "country" else token)
if control.type in ("password", "email"):
return None
searchable = _normalize_text(" ".join(part for part in (control.name, control.label) if part))
for pattern, token in _CHECKOUT_HEURISTICS:
if pattern.search(searchable):
return ClassifiedLoginControl(control, 70, token)
return None
def select_checkout_fills(classified: List[ClassifiedLoginControl], secret: Dict[str, str],
field_tokens: Dict[str, str]) -> List[Dict[str, Any]]:
"""Map a payment/address secret payload onto the best control per autocomplete token.
``field_tokens`` is ``PAYMENT_FIELDS`` / ``ADDRESS_FIELDS`` (agent/vault_store.py). A combined
``cc-exp`` control gets ``MM/YY`` from exp_month + exp_year and then suppresses the separate
month/year fills. Returns ``[{"index", "token", "value"}]``: one control per token, highest
score then DOM order.
"""
values: Dict[str, str] = {tok: secret[f] for f, tok in field_tokens.items() if secret.get(f)}
if "cc-exp-month" in values and "cc-exp-year" in values:
values["cc-exp"] = f"{values['cc-exp-month'].zfill(2)}/{values['cc-exp-year'][-2:]}"
fills: List[Dict[str, Any]] = []
for token, value in values.items():
candidates = sorted((c for c in classified if c.token == token), key=lambda c: (-c.score, c.control.index))
if candidates:
fills.append({"index": candidates[0].control.index, "token": token, "value": value})
if any(f["token"] == "cc-exp" for f in fills):
fills = [f for f in fills if f["token"] not in ("cc-exp-month", "cc-exp-year")]
return fills
# JS expression evaluated in the page to inspect candidate input controls.
# Ported from OpenInstinct's nativeLoginControlInspectionExpression.
# Inspection stamps every input with ``<nonce>:<index>`` under a per-inspection attribute; the fill
@@ -145,7 +204,7 @@ def build_inspection_js(nonce: str) -> str:
_LOGIN_CONTROL_INSPECTION_JS_TEMPLATE = """(() => {
const nonce = __NONCE__;
const elements = Array.from(document.querySelectorAll("input"));
const elements = Array.from(document.querySelectorAll("input, select"));
const forms = Array.from(document.forms);
elements.forEach((element, index) => element.setAttribute("data-hermes-vault-slot", nonce + ":" + index));
const out = elements.flatMap((element, index) => {
@@ -171,7 +230,7 @@ _LOGIN_CONTROL_INSPECTION_JS_TEMPLATE = """(() => {
element.getAttribute("title") || "",
].join(" "),
name: [element.name, element.id].join(" "),
type: element.type || "",
type: element.tagName === "SELECT" ? "select" : (element.type || ""),
}];
});
return JSON.stringify(out);
@@ -179,44 +238,51 @@ _LOGIN_CONTROL_INSPECTION_JS_TEMPLATE = """(() => {
def build_fill_js(fills: List[Dict[str, Any]], expected_origin: str, nonce: str = "") -> str:
"""Build a JS expression that fills the selected inputs and reports
only a count. The returned expression never echoes the values back.
"""Build a JS expression that fills the selected controls 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.
``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>}``:
proof scope equals mutation scope (#88706). Targets resolve by the ``<nonce>:<index>`` stamp of
THIS inspection; a ``current-password`` fill additionally requires ``type=password``; ``<select>``
controls (country, state, expiry month) match an option by value or visible text. No marker is
left on filled controls so later model-driven DOM reads cannot address them deterministically.
"""
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"
f" const nonce = {json.dumps(nonce)};\n"
" let filled = 0;\n"
" for (const f of fills) {\n"
" const el = document.querySelector('input[data-hermes-vault-slot=\"' + nonce + ':' + f.index + '\"]');\n"
" if (!el || el.type !== \"password\") 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"
" document.querySelectorAll(\"[data-hermes-vault-slot]\").forEach((n) => n.removeAttribute(\"data-hermes-vault-slot\"));\n"
" return JSON.stringify({ filled });\n"
"})()"
[{"index": f["index"], "token": f.get("token", "current-password"), "value": f["value"]} for f in fills]
)
return (_FILL_JS_TEMPLATE.replace("__EXPECTED_ORIGIN__", json.dumps(expected_origin))
.replace("__FILLS__", payload).replace("__NONCE__", json.dumps(nonce)))
_FILL_JS_TEMPLATE = """(() => {
const expectedOrigin = __EXPECTED_ORIGIN__;
if (window.location.origin !== expectedOrigin) {
return JSON.stringify({ refused: "origin_changed", found: window.location.origin });
}
const fills = __FILLS__;
const nonce = __NONCE__;
let filled = 0;
const norm = (t) => String(t || "").trim().toLowerCase();
for (const f of fills) {
const el = document.querySelector('[data-hermes-vault-slot="' + nonce + ':' + f.index + '"]');
if (!el || (f.token === "current-password" && el.type !== "password")) continue;
try {
if (el.tagName === "SELECT") {
const want = norm(f.value);
const opt = Array.from(el.options).find((o) => [o.value, o.textContent].some((t) => norm(t) === want || norm(t) === want.replace(/^0/, "")));
if (opt) { el.value = opt.value; el.dispatchEvent(new Event("change", { bubbles: true })); filled += 1; }
continue;
}
el.focus();
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value");
if (setter && setter.set) { setter.set.call(el, f.value); } else { el.value = f.value; }
el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }));
el.dispatchEvent(new Event("change", { bubbles: true }));
if (el.value.length > 0) filled += 1;
} catch (e) { /* skip */ }
}
document.querySelectorAll("[data-hermes-vault-slot]").forEach((n) => n.removeAttribute("data-hermes-vault-slot"));
return JSON.stringify({ filled });
})()"""
@@ -330,16 +330,10 @@ export function VaultSettings() {
return
}
const needsOrigin = form.kind === 'login'
// Every kind is filled only on the origin it was saved for; a card without an origin is unfillable.
const origin = form.origin.trim()
if (needsOrigin && !isValidOrigin(origin)) {
setFormError(v.originInvalid)
return
}
if (!needsOrigin && origin && !isValidOrigin(origin)) {
if (!isValidOrigin(origin)) {
setFormError(v.originInvalid)
return
@@ -584,17 +578,18 @@ export function VaultSettings() {
</Field>
</div>
<Field htmlFor="vault-origin" label={v.originField}>
<Input
id="vault-origin"
inputMode="url"
onChange={e => setForm(f => ({ ...f, origin: e.target.value }))}
placeholder={form.kind === 'login' ? v.originPlaceholder : v.originPlaceholderCheckout}
value={form.origin}
/>
</Field>
{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
+1
View File
@@ -407,6 +407,7 @@ export const ar = defineLocale({
labelRequired: 'التسمية مطلوبة.',
originField: 'أصل الموقع',
originPlaceholder: 'https://github.com',
originPlaceholderCheckout: 'https://shop.example.com',
originInvalid: 'أدخل عنوان URL صالحاً مثل https://example.com.',
identifierTypeField: 'نوع المعرّف',
identifierTypes: { email: 'البريد الإلكتروني', phone: 'الهاتف', username: 'اسم المستخدم' },
+1
View File
@@ -532,6 +532,7 @@ export const en: Translations = {
labelRequired: 'A label is required.',
originField: 'Site origin',
originPlaceholder: 'https://github.com',
originPlaceholderCheckout: 'https://shop.example.com',
originInvalid: 'Enter a valid URL like https://example.com.',
identifierTypeField: 'Identifier type',
identifierTypes: { email: 'Email', phone: 'Phone', username: 'Username' },
+1
View File
@@ -353,6 +353,7 @@ export const ja = defineLocale({
labelRequired: 'ラベルは必須です。',
originField: 'サイトのオリジン',
originPlaceholder: 'https://github.com',
originPlaceholderCheckout: 'https://shop.example.com',
originInvalid: 'https://example.com のような有効な URL を入力してください。',
identifierTypeField: '識別子の種類',
identifierTypes: { email: 'メール', phone: '電話番号', username: 'ユーザー名' },
+1
View File
@@ -466,6 +466,7 @@ export interface Translations {
labelRequired: string
originField: string
originPlaceholder: string
originPlaceholderCheckout: string
originInvalid: string
identifierTypeField: string
identifierTypes: Record<'email' | 'phone' | 'username', string>
+1
View File
@@ -342,6 +342,7 @@ export const zhHant = defineLocale({
labelRequired: '標籤為必填。',
originField: '網站來源',
originPlaceholder: 'https://github.com',
originPlaceholderCheckout: 'https://shop.example.com',
originInvalid: '請輸入有效的 URL,例如 https://example.com。',
identifierTypeField: '識別碼類型',
identifierTypes: { email: '電子郵件', phone: '電話', username: '使用者名稱' },
+1
View File
@@ -448,6 +448,7 @@ export const zh: Translations = {
labelRequired: '标签为必填项。',
originField: '站点来源',
originPlaceholder: 'https://github.com',
originPlaceholderCheckout: 'https://shop.example.com',
originInvalid: '请输入有效的 URL,例如 https://example.com。',
identifierTypeField: '标识符类型',
identifierTypes: { email: '邮箱', phone: '电话', username: '用户名' },
+125
View File
@@ -0,0 +1,125 @@
"""Live E2E: vault fill through the REAL browser_exec path (Browser Use CLI + Hermes' packaged Chromium).
Proves problem (1) of the #106480 re-review is fixed: on the default browser backend the login page lives in
a tab browser_exec opened, the supervisor is attached by browser_exec itself, browser_vault_fill focuses the
tab on the bound origin and injects the password over the supervisor's CDP WebSocket, and the secret is absent
from every model-facing result. Also exercises a payment fill (confirm gate, card fields, decline = no write).
Run: HERMES_E2E_BROWSER=1 <venv>/bin/python evals/vault_fill_live_e2e.py
"""
from __future__ import annotations
import http.server
import json
import os
import sys
import tempfile
import threading
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
HOME = Path(tempfile.mkdtemp(prefix="hermes-vault-e2e-"))
os.environ["HERMES_HOME"] = str(HOME)
PAGES = {
"/login": b"""<!doctype html><title>login</title>
<form><input name=email type=email autocomplete=username><input name=pw type=password autocomplete=current-password>
<input type=submit></form>""",
"/checkout": b"""<!doctype html><title>checkout</title>
<form><input name=cardnum placeholder="Card number"><input name=exp placeholder="Expiry (MM/YY)">
<input name=cvv placeholder="CVC"><select name=country><option value=DE>Germany<option value=US>United States</select>
<input name=email type=email></form>""",
}
TASK = "vault-e2e"
class _Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
body = PAGES.get(self.path, b"nope")
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): # noqa: ARG002 — quiet
pass
def _exec(code: str) -> dict:
from tools import browser_use_cli as bu
out = json.loads(bu.browser_exec(code, task_id=TASK, timeout_s=90))
assert out.get("success"), out
return out
def main() -> int:
srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
origin = f"http://127.0.0.1:{srv.server_address[1]}"
from tools.browser_supervisor import SUPERVISOR_REGISTRY
try:
# browser_exec opens its own tabs; the login page is deliberately NOT the first one.
_exec("new_tab('about:blank')")
_exec(f"new_tab({origin + '/login'!r}); wait_for_load()")
_exec(f"new_tab({origin + '/checkout'!r}); wait_for_load(); print(page_info()['url'])")
sup = SUPERVISOR_REGISTRY.get(TASK)
assert sup is not None, "browser_exec did not attach a supervisor for its task (problem 1 regressed)"
print("supervisor attached by browser_exec; its page before fill:", sup.evaluate_runtime("location.href")["result"])
from tools import browser_vault_tool as bvt
from tools.browser_cdp_tool import _redact_cdp_output
from agent.vault_store import get_vault_store
from agent import redact
import tools.approval_prompt as ap
store = get_vault_store()
login = store.add_item("login", "site", {"identifier_type": "email", "identifier": "a@b.c", "password": "pw-E2E-8842"}, origin=origin)
card = store.add_item("payment", "visa", {"card_number": "4111111111111111", "exp_month": "7", "exp_year": "2029", "cvc": "987"}, origin=origin)
raw = bvt.browser_vault_fill(login.id, task_id=TASK)
out = json.loads(raw)
print("login fill:", out)
assert out["success"] and out["filled_fields"] == 1, out
assert "pw-E2E-8842" not in raw
dom = sup.evaluate_runtime("location.pathname + ' ' + document.querySelector('input[name=pw]').value")
assert dom["result"] == "/login pw-E2E-8842", dom # raw supervisor read (not a model surface): the write landed in the login tab
assert "pw-E2E-8842" not in json.dumps(_redact_cdp_output({"result": {"value": dom["result"]}}))
print("login: password landed in the /login tab; model-facing read is scrubbed")
ap.request_elicitation_consent = lambda *a, **k: "decline"
out = json.loads(bvt.browser_vault_fill(card.id, task_id=TASK))
assert out["error_type"] == "payment_declined", out
assert sup.focus_page(origin, accept=bvt._TAB_PROBES["payment"])["ok"]
r = sup.evaluate_runtime("['cardnum','exp','cvv'].map(n => document.querySelector('[name='+n+']').value).join('|')")
assert r.get("result") == "||", r
print("payment: declined confirmation wrote nothing")
ap.request_elicitation_consent = lambda *a, **k: "accept"
raw = bvt.browser_vault_fill(card.id, task_id=TASK)
out = json.loads(raw)
print("payment fill:", out)
assert out["success"] and out["filled_fields"] == 3 and out["fields"] == ["cc-csc", "cc-exp", "cc-number"], out
assert "4111" not in raw and "987" not in raw
r = sup.evaluate_runtime("['cardnum','exp','cvv','country','email'].map(n => document.querySelector('[name='+n+']').value).join('|')")
assert r.get("result") == "4111111111111111|07/29|987|DE|", r
print("payment: card/expiry/cvc filled on the /checkout tab, email untouched, select untouched without a value")
redact.clear_vault_redaction_values()
print("E2E OK")
return 0
finally:
SUPERVISOR_REGISTRY.stop_all()
try:
from tools.browser_tool_lifecycle import cleanup_all_browsers
cleanup_all_browsers()
except Exception:
pass
srv.shutdown()
if __name__ == "__main__":
raise SystemExit(main())
+17 -15
View File
@@ -81,22 +81,24 @@ def _cmd_add(args) -> None:
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.[/]"
)
from agent.vault_store import ADDRESS_FIELDS, PAYMENT_FIELDS, REQUIRED_FIELDS
fields = PAYMENT_FIELDS if kind == "payment" else ADDRESS_FIELDS
origin = ""
while not origin:
origin = input("Site origin the item may be filled on (e.g. https://shop.example.com): ").strip()
c.print(f"[dim]{kind} fields are filled only on that origin; card values are read hidden.[/]")
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
)
for field in fields:
required = field in REQUIRED_FIELDS[kind]
prompt = f"{field.replace('_', ' ')}{'' if required else ' (optional)'}: "
read = getpass.getpass if kind == "payment" else input
value = read(prompt).strip()
while required and not value:
value = read(prompt).strip()
if value:
secret[field] = value
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
+86 -14
View File
@@ -114,11 +114,19 @@ class TestVaultStore:
)
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"})
store.add_item(kind="payment", label="Card", origin="https://shop.test", secret=_CARD)
store.add_item(kind="address", label="Home", secret=_ADDRESS)
kinds = {m.kind for m in store.list_items()}
assert kinds == {"payment", "address"}
def test_checkout_kinds_keep_only_canonical_fields(self, store):
"""The fill maps canonical names → autocomplete tokens; a stray ad-hoc key would be stored (secret!)
yet unfillable, and a missing required field would make the item dead on every checkout."""
meta = store.add_item(kind="payment", label="Card", secret={**_CARD, "note": "personal"})
assert "note" not in store.resolve_secret(meta.id)
with pytest.raises(VaultError, match="cvc"):
store.add_item(kind="payment", label="Card", secret={k: v for k, v in _CARD.items() if k != "cvc"})
def test_unknown_kind_rejected(self, store):
with pytest.raises(VaultError):
store.add_item(kind="totp", label="x", secret={})
@@ -146,6 +154,11 @@ class TestVaultStore:
# Classifier
# ---------------------------------------------------------------------------
_CARD = {"card_number": "4111111111111111", "cardholder_name": "A User", "exp_month": "7", "exp_year": "2029",
"cvc": "123", "billing_postal_code": "94110"}
_ADDRESS = {"address_line1": "1 Main St", "city": "Springfield", "postal_code": "12345", "country": "US"}
def _ctrl(**kw):
base = dict(autocomplete="", form_index=0, index=0, label="", name="", type="text")
base.update(kw)
@@ -225,7 +238,8 @@ class TestClassifier:
assert "vaultSecret" not in js
assert "data-vault-secret" not in js
assert "elements[f.index]" not in js
assert "input[data-hermes-vault-slot=" in js and "nonce + ':' + f.index" in js and 'el.type !== "password"' in js
assert "[data-hermes-vault-slot=" in js and "nonce + ':' + f.index" in js
assert 'f.token === "current-password" && el.type !== "password"' in js # a password fill never lands in a text box
assert js.index('removeAttribute("data-hermes-vault-slot")') > js.index("setter.set.call")
def test_build_fill_js_asserts_origin_before_any_write(self):
@@ -457,17 +471,56 @@ class TestBrowserVaultTools:
# 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)
redact.clear_vault_redaction_values()
def test_fill_rejects_non_login_kind(self, store):
def test_payment_fill_requires_confirmation_then_fills_card_fields(self, store):
"""A card is written only after the user confirms (a prompt injection reaching a checkout must not be
able to spend); the secret eval then targets the classified card controls and the result carries
the field tokens but never a value."""
from tools import browser_vault_tool
from agent import redact
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"]
meta = store.add_item(kind="payment", label="Visa", origin="https://shop.test", secret=_CARD)
controls = [
{"autocomplete": "cc-number", "index": 0, "type": "text"},
{"label": "Expiry (MM/YY)", "index": 1, "type": "text"},
{"label": "CVC", "index": 2, "type": "text"},
{"autocomplete": "email", "index": 3, "type": "email"},
]
def fake_eval(task_id, expression):
if "location.href" in expression:
return {"success": True, "result": "https://shop.test/checkout"}
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": 3})}
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), \
patch("tools.approval_prompt.request_elicitation_consent", return_value="decline"):
declined = json.loads(browser_vault_tool.browser_vault_fill(meta.id))
assert declined["success"] is False and declined["error_type"] == "payment_declined"
assert secret_exprs == []
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), \
patch("tools.approval_prompt.request_elicitation_consent", return_value="accept"):
raw = browser_vault_tool.browser_vault_fill(meta.id)
out = json.loads(raw)
assert out["success"] is True and out["fields"] == ["cc-csc", "cc-exp", "cc-number"]
assert _CARD["card_number"] not in raw and _CARD["cvc"] not in raw
assert len(secret_exprs) == 1 and _CARD["card_number"] in secret_exprs[0] and "07/29" in secret_exprs[0]
assert '"index": 3' not in secret_exprs[0] # the email box is never a card target
assert _CARD["card_number"] not in redact.redact_sensitive_text(f"dom says {_CARD['card_number']}")
finally:
redact.clear_vault_redaction_values()
class TestVaultHardening:
@@ -483,8 +536,8 @@ class TestVaultHardening:
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")
(vault / "vault.key").write_text("k", encoding="utf-8")
(vault / "vault.json.enc").write_text("blob", encoding="utf-8")
monkeypatch.setattr(fs, "_hermes_home_path", lambda: home)
for target in (vault, vault / "vault.key", vault / "vault.json.enc"):
@@ -499,7 +552,7 @@ class TestVaultHardening:
other = home / "vaults-notes"
other.mkdir(parents=True)
f = other / "notes.txt"
f.write_text("hi")
f.write_text("hi", encoding="utf-8")
monkeypatch.setattr(fs, "_hermes_home_path", lambda: home)
assert fs.get_read_block_error(str(f)) is None
@@ -520,3 +573,22 @@ class TestVaultHardening:
store = VaultStore(base_dir=tmp_path / "vault")
store._ensure_dir()
assert called.call_count == 1
class TestVaultSchemaCrossToolset:
def test_vault_schemas_name_the_input_tool_of_the_active_browser_stack(self):
"""The vault tools sit in `browser`; the tool that types the identifier lives in `browser-use`
(`fill_input` inside browser_exec) or is browser_type. A static name would be a ghost on one stack,
so model_tools resolves it per session from the tools actually present."""
import model_tools
from tools.browser_vault_tool import BROWSER_VAULT_FILL_SCHEMA
assert "fill_input" not in BROWSER_VAULT_FILL_SCHEMA["description"]
base = model_tools._fn_def(dict(BROWSER_VAULT_FILL_SCHEMA))
with_exec = model_tools._apply_dynamic_schemas([base, model_tools._fn_def({"name": "browser_exec", "description": "x"}),
model_tools._fn_def({"name": "terminal", "description": "x"})])
with_builtin = model_tools._apply_dynamic_schemas([base, model_tools._fn_def({"name": "browser_type", "description": "x"})])
desc_exec = with_exec[0]["function"]["description"]
desc_builtin = with_builtin[0]["function"]["description"]
assert "`fill_input` inside browser_exec" in desc_exec and "browser_type" not in desc_exec
assert "browser_type" in desc_builtin and "fill_input" not in desc_builtin
+85 -37
View File
@@ -8,8 +8,9 @@ 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
- ``browser_vault_fill`` → server-side fill of the CURRENT page from a vault
handle: the password field for logins, card fields for payment items (after
the user confirms), address fields for address items. The secret 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
@@ -38,7 +39,9 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
def _check_vault_available() -> bool:
"""Schema-gate: the tools appear only when the local vault has items or an external manager is enabled."""
"""Schema-gate: the tools appear only when the local vault has items or an external manager is enabled.
Registered uncached: the answer is per profile (vault dir + config) and the registry's TTL cache is keyed
per profile only under multiplex; the probe is a local file stat, cheap enough to run every pass."""
try:
from agent.vault_backends import enabled_backends
from agent.vault_store import get_vault_store
@@ -153,6 +156,29 @@ def _current_page_origin(task_id: str) -> Optional[str]:
return None
# Per kind: a JS probe that is truthy on a tab holding the form this kind fills.
_TAB_PROBES = {
"login": "!!document.querySelector('input[type=password]')",
"payment": "!!document.querySelector('input[autocomplete^=cc-], [name*=card i], [placeholder*=card i], [name*=cvc i], [name*=cvv i]')",
"address": "!!document.querySelector('input[autocomplete^=address-], [autocomplete=postal-code], [name*=address i], [name*=zip i], [name*=postal i]')",
}
def _focus_bound_origin(task_id: str, origin: str, kind: str) -> Optional[str]:
"""Point the supervisor's page session at the open tab on ``origin`` that holds a ``kind`` form
(browser_exec sessions open their own tabs, so the tab the supervisor attached to first is rarely the
login page). Returns the origin when a tab was focused, else None (caller falls back to the current page)."""
try:
from tools.browser_supervisor import SUPERVISOR_REGISTRY
supervisor = SUPERVISOR_REGISTRY.get(task_id)
except Exception:
supervisor = None
if supervisor is None:
return None
return origin if supervisor.focus_page(origin, accept=_TAB_PROBES.get(kind)).get("ok") else None
# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------
@@ -179,7 +205,7 @@ def browser_vault_list() -> str:
continue
for meta in metas:
entry = {"handle": meta.id, "backend": backend.name, "label": meta.label, "kind": meta.kind,
"origin": meta.origin, "available": meta.kind == "login"}
"origin": meta.origin, "available": meta.kind == "login" or bool(meta.origin)}
if meta.identifier:
entry["identifier"] = meta.identifier
entry["identifier_type"] = meta.identifier_type
@@ -235,11 +261,13 @@ def browser_vault_fill(handle: str, task_id: Optional[str] = None) -> str:
LoginControl,
build_fill_js,
build_inspection_js,
classify_checkout_control,
classify_login_control,
select_checkout_fills,
select_password_fill,
)
from agent.vault_backends import UnlockRequired, backend_for_handle
from agent.vault_store import scrub_secret_from_text
from agent.vault_store import ADDRESS_FIELDS, PAYMENT_FIELDS, scrub_secret_from_text
effective_task_id = task_id or "default"
backend = backend_for_handle(handle)
@@ -264,14 +292,16 @@ def browser_vault_fill(handle: str, task_id: Optional[str] = None) -> str:
),
}
)
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."}
)
if meta.kind != "login" and not meta.origin:
return json.dumps({"success": False, "error_type": "no_origin",
"error": f"Vault item {handle!r} has no bound origin; {meta.kind} items are filled only on the site they were saved for."})
if meta.kind == "payment" and not _confirm_payment_fill(meta.label, str(meta.origin)):
return json.dumps({"success": False, "error_type": "payment_declined",
"error": "The user did not confirm filling this payment card. Do not retry; ask them instead."})
# ── 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)
page_origin = _focus_bound_origin(effective_task_id, str(meta.origin), meta.kind) or _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."}
@@ -302,33 +332,39 @@ def browser_vault_fill(handle: str, task_id: Optional[str] = None) -> str:
if not isinstance(raw_controls, list):
return json.dumps({"success": False, "error": "Page input inspection returned no usable controls."})
classify = classify_login_control if meta.kind == "login" else classify_checkout_control
classified: list[ClassifiedLoginControl] = []
for raw in raw_controls:
if not isinstance(raw, dict):
continue
result = classify_login_control(LoginControl.from_dict(raw))
result = classify(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."})
return json.dumps({"success": False, "error": f"No {meta.kind} form fields were found on the current page."})
# ── Resolve secret and fill (secret never enters any logged string) ─────
try:
password = backend.resolve_password(handle)
if meta.kind == "login":
secret = {"password": backend.resolve_password(handle)}
fills = select_password_fill(classified, secret["password"])
else:
secret = backend.resolve_secret(handle)
fills = select_checkout_fills(classified, secret, PAYMENT_FIELDS if meta.kind == "payment" else ADDRESS_FIELDS)
except UnlockRequired:
return json.dumps({"success": False, "error_type": "unlock_required",
"error": f"{backend.display_name} locked again; call browser_vault_unlock."})
secret = {"password": password}
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?)."}
{"success": False, "error": f"No fillable {meta.kind} field matched the saved item 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)
# Address values are not secrets but the card fields are: register every payment value.
for value in (secret.values() if meta.kind == "payment" else [secret.get("password", "")]):
register_vault_redaction_value(value)
try:
fill_result = _eval_js_secret(
@@ -364,15 +400,24 @@ def browser_vault_fill(handle: str, task_id: Optional[str] = None) -> str:
)
filled = parsed.get("filled", 0) if isinstance(parsed, dict) else 0
return json.dumps(
{
"success": bool(filled),
"filled_fields": int(filled),
"backend": backend.name,
"kind": meta.kind,
"origin": meta.origin,
}
)
out = {"success": bool(filled), "filled_fields": int(filled), "backend": backend.name,
"kind": meta.kind, "origin": meta.origin}
if meta.kind != "login":
out["fields"] = sorted(f["token"] for f in fills) # which controls were targeted, never the values
return json.dumps(out)
def _confirm_payment_fill(label: str, origin: str) -> bool:
"""Human confirmation before a card is written into a page: a prompt injection that reaches a checkout
must not be able to spend. Routes through the approval surface of the active session (gateway button
round-trip or CLI panel); headless sessions cannot confirm and the fill is refused."""
from tools.approval_prompt import request_elicitation_consent
return request_elicitation_consent(
f"Fill payment card '{label}' on {origin}",
"The agent wants to enter your saved card details into this checkout page. The card number and "
"CVC never enter the conversation. Approve only if you intend to pay here.",
surface="vault-payment") == "accept"
# ---------------------------------------------------------------------------
@@ -382,13 +427,13 @@ def browser_vault_fill(handle: str, task_id: Optional[str] = None) -> str:
BROWSER_VAULT_LIST_SCHEMA = {
"name": "browser_vault_list",
"description": (
"List saved website logins as handles with metadata (label, backend, bound origin, and the "
"identifier + identifier_type so you can type the username yourself with fill_input). "
"Passwords are NEVER returned. Sources: the local Hermes vault plus any enabled password "
"List saved website logins, payment cards and addresses as handles with metadata (kind, label, "
"backend, bound origin; logins also carry identifier + identifier_type so you can type the username "
"yourself with the browser's input tool). Secret values are NEVER returned. Sources: the local Hermes vault plus any enabled password "
"manager (1Password, Bitwarden). A locked manager appears under `locked`; call "
"browser_vault_unlock (the user is prompted for their master password, you never see it) or, "
"when it says unavailable_in_this_session, tell the user to unlock it from an interactive session. "
"Workflow: fill_input the identifier, then browser_vault_fill with the handle."
"Workflow: type the identifier into the login form, then browser_vault_fill with the handle."
),
"input_schema": {"type": "object", "properties": {}, "required": []},
}
@@ -411,12 +456,13 @@ BROWSER_VAULT_UNLOCK_SCHEMA = {
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, then "
"call this. The password is resolved from the local vault or the password manager 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). If the manager is "
"locked the user is prompted to unlock first."
"Fill the CURRENT browser page from a vault handle (see browser_vault_list): a login item fills ONLY "
"the password field (type the identifier/username yourself first with the browser's input tool); a "
"payment item fills card number/name/expiry/CVC after the user confirms in their UI; an address item "
"fills the address fields. Values are resolved server-side and never appear in the conversation. "
"Refused unless the page origin exactly matches the item's bound origin (re-checked atomically at "
"fill time). If a password manager is locked the user is prompted to unlock first. Never retry a "
"payment_declined result."
),
"input_schema": {
"type": "object",
@@ -445,7 +491,9 @@ def _handle_vault_fill(args: Dict[str, Any], **kwargs) -> str:
)
from tools.registry import registry # noqa: E402
from tools.registry import no_cache_check_fn, registry # noqa: E402
_check_vault_available = no_cache_check_fn(_check_vault_available)
registry.register(
name="browser_vault_list",
@@ -51,9 +51,29 @@ hermes vault list
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.
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)
@@ -144,11 +164,22 @@ 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: fill_input(<username field>, "me@example.com")
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,
@@ -162,10 +193,15 @@ Agent: browser_click(<submit>)
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)
- **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.