diff --git a/agent/vault_backends/base.py b/agent/vault_backends/base.py index 5886635796..f7a23fe440 100644 --- a/agent/vault_backends/base.py +++ b/agent/vault_backends/base.py @@ -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: diff --git a/agent/vault_backends/local.py b/agent/vault_backends/local.py index ccb87e5680..dfb86d1592 100644 --- a/agent/vault_backends/local.py +++ b/agent/vault_backends/local.py @@ -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()} diff --git a/agent/vault_login_classifier.py b/agent/vault_login_classifier.py index 3468d2c481..e9a9dfb7ea 100644 --- a/agent/vault_login_classifier.py +++ b/agent/vault_login_classifier.py @@ -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 ``:`` 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": }`` — 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": }``: + proof scope equals mutation scope (#88706). Targets resolve by the ``:`` stamp of + THIS inspection; a ``current-password`` fill additionally requires ``type=password``; `` setForm(f => ({ ...f, origin: e.target.value }))} + placeholder={form.kind === 'login' ? v.originPlaceholder : v.originPlaceholderCheckout} + value={form.origin} + /> + + {form.kind === 'login' && ( <> - - setForm(f => ({ ...f, origin: e.target.value }))} - placeholder={v.originPlaceholder} - value={form.origin} - /> -
+""", + "/checkout": b"""checkout +
+ +
""", +} +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()) diff --git a/hermes_cli/vault.py b/hermes_cli/vault.py index 195bc31fc4..9998626711 100644 --- a/hermes_cli/vault.py +++ b/hermes_cli/vault.py @@ -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 diff --git a/tests/test_browser_vault.py b/tests/test_browser_vault.py index a654eddac4..c6551b9c42 100644 --- a/tests/test_browser_vault.py +++ b/tests/test_browser_vault.py @@ -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 diff --git a/tools/browser_vault_tool.py b/tools/browser_vault_tool.py index 37d075752a..160bc41807 100644 --- a/tools/browser_vault_tool.py +++ b/tools/browser_vault_tool.py @@ -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", diff --git a/website/docs/user-guide/features/credential-vault.md b/website/docs/user-guide/features/credential-vault.md index d2360934fa..7a0bcc051c 100644 --- a/website/docs/user-guide/features/credential-vault.md +++ b/website/docs/user-guide/features/credential-vault.md @@ -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 `