The Anima Vault Extension: Logins Your Agent Never Sees

The extension fills passwords into real login forms. The plaintext is resolved server-side and delivered to the browser, never through your agent.

Diyan BogdanovDiyan Bogdanov5 min read
#security#vault#browser-extension

Most of the web has no API. When an agent needs to do something on a site that was only ever built for a human, the usual answer is to hand the agent a password and let it type.

That answer has a specific cost, and it is not the one people usually name. The risk is not that the agent behaves badly. It is that the password is now in a context window — which means it is in the prompt, probably in a log, possibly in a model provider's retention window, and certainly in a place you cannot enumerate after the fact.

The Anima Vault extension exists so the agent can drive a login it never receives.

The agent asks for a fill. It does not get the credential.#

The interesting part of the design is where the plaintext travels.

When an agent calls fill_login, it forwards a credentialId and a short-lived vault token. It does not forward a password, because it does not have one. The API — running on the instance that holds the live WebSocket to your browser — resolves the credential from the vault itself, delivers it straight to the extension over that socket, and rewrites the agent's arguments so the dispatched call carries only the token handle.

agent  ──  fill_login { credentialId, vaultToken }  ──▶  API
                                                          │  resolves from vault
browser ◀──  store_vault_credential { vaultToken, … } ────┤
browser ◀──  fill_login { vaultToken }  ──────────────────┘

The plaintext takes the API-to-browser edge. It never takes the agent edge. In the source this is marked as a locked decision — the dispatch hook's contract says the caller forwards a token "NEVER the plaintext".

What stops an agent from simply asking for the plaintext directly? Scope. The endpoint that exchanges a vault token for a readable secret is gated to master keys and keys holding vault:inject or extension:bridge. A plain agent key is refused, which is what makes it safe for the exchange to sit on an agent-facing surface at all.

What the extension refuses to do#

The extension is not a delivery mechanism that trusts its caller. It re-checks the thing that matters most, and it fails closed.

Every credential can carry URIs describing where it belongs, with a matching strategy:

StrategyMatches
domainThe host, plus its subdomains
hostThat exact host only
starts_withURLs with the given prefix
regexA pattern you supply
neverNothing — an explicit opt-out

Before filling, the extension reads the destination tab's URL and checks the credential against it. If they do not match, it refuses and says which site it was asked to fill.

The part worth stealing is the failure mode. If the extension cannot read the tab's URL — a missing host permission, a chrome:// page, a sandboxed frame — it also refuses. The credential-to-site binding is the defence against filling a password into a phishing origin, and a defence you cannot evaluate is not one you can assume. A real login is always on a readable page, so nothing legitimate is blocked by this.

What the agent cannot see#

An agent that can read a page can read what is already typed into it. So before any login detection result crosses the bridge, the currentValue of every password and TOTP field is blanked.

The content script that performs detection already avoids capturing those values. The redaction runs anyway, at the trust boundary, because a never-see guarantee that depends on one upstream function staying correct is a guarantee with a single point of failure. Two independent places have to break before a secret escapes.

When a fill completes, what returns to the agent is { filledFields, failedFields } — which fields took a value and which did not. Never the values.

What the agent cannot do#

The bridge exposes twelve tools, and the list is short enough to read:

detect_login, get_login_fields, fill_login, store_vault_credential, detect_checkout, fill_card, pay_checkout, store_card_data, request_confirmation, get_address_fields, fill_address, fill_verified_address.

There is no click, no navigate, no type, and no screenshot. An agent connected to your browser cannot drive it. It can ask what a form contains and ask for a credential to be placed into that form — nothing else.

The whitelist and the argument schema are the same table, so a tool that is not listed has no schema and cannot be called. Arguments are walked recursively: strings are length-capped, unsupported types are rejected outright, and the error names the exact path (args.addressData.profile.line1) rather than failing somewhere generic.

The channel itself#

Every bridge message is HMAC-signed with a per-key secret, and the receiver rejects it on any of three grounds:

  • Stale — more than 30 seconds old.
  • Replayed id — a message id already seen.
  • Replayed nonce — a nonce already seen.

The last two are tracked over a rolling window of 512 values each. Both are checked because they fail differently: an id is chosen by the sender and may be reused by a buggy client, while a nonce is random and a repeat is evidence rather than sloppiness.

The extension's own bridge credentials live in chrome.storage.session. That survives a service-worker restart, which is what makes reconnection work, and evaporates when the browser closes — so key material never reaches disk-backed storage.

How long a secret exists in your browser#

Thirty seconds, once, at most.

A credential arrives against a token, is held only in memory, and is consumed by a single fill. The second consume returns nothing, and the token is already invalid. A sweep runs every ten seconds for anything that expired unused.

On zeroization, precisely

Consuming a credential overwrites each string field with null bytes and then empties it, before dropping the entry.

Worth being exact about what that buys, because JavaScript strings are immutable: the overwrite replaces the reference, it does not scrub the original allocation, which survives until garbage collection. What genuinely matters is that the last reference is dropped promptly and nothing was ever written to storage. The overwrite is a cheap extra, not the guarantee.

Payments work differently, and the difference matters#

The extension also fills cards, and detects checkouts behind Stripe, Braintree and Adyen. Two things about that path are not the same as logins, and assuming otherwise would be the expensive mistake.

Card data comes from the caller, not from the vault

There is no resolve-on-owner step for cards. Where fill_login causes the API to fetch the credential itself, store_card_data carries the card number, expiry and CVC in its arguments — supplied by whoever made the call.

So the "your agent never holds the secret" property is specific to the login path today. If you route card data through an agent, the agent has the card.

Human confirmation gates pay_checkout, not fill_card

pay_checkout requests confirmation first and only fills if it is approved. The popup shows the merchant, amount, currency, the card's last four digits and the governing policy, with an optional one-time-code step, and the request expires after two minutes — after which approval is refused rather than granted late.

fill_card does not do any of that. It requires a checkout already detected on that tab and a valid unconsumed token, and then it fills. The approval step is a property of the tool the caller chooses, not a floor under the payment path.

If you want a human in the loop on payments, call pay_checkout and restrict what else can be called. Do not infer the gate from the fact that one exists.

The permission shape#

Two lines of the manifest tell you most of what you need:

  • host_permissions is only api.useanima.sh and connect.useanima.sh. The privileged channel reaches Anima and nothing else.
  • content_scripts.matches is <all_urls>, because a login form can be on any site.

That asymmetry is the whole design in miniature. The script that touches arbitrary pages has no privileged access, and the code with privileged access never touches arbitrary pages. The extension page's content-security policy is script-src 'self' with framing denied outright, and messages from web pages are refused unless the origin is explicitly allowed — the Anima console is the only one that is.

Where this leaves you#

An agent that can log into a site it has no API for is a genuinely larger agent. The question is what you had to give it to get there.

Here, the answer is a credential ID and a token that expires in thirty seconds. The password went from the vault to your browser without passing through the model, the prompt, or the logs — and if it had been pointed at the wrong site, the extension would have declined rather than typed it.

Stay Updated

Get the latest on AI agent identity, delivered weekly.