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:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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: 'اسم المستخدم' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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: 'ユーザー名' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '使用者名稱' },
|
||||
|
||||
@@ -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: '用户名' },
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user