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.
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:
| Strategy | Matches |
|---|---|
domain | The host, plus its subdomains |
host | That exact host only |
starts_with | URLs with the given prefix |
regex | A pattern you supply |
never | Nothing — 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_permissionsis onlyapi.useanima.shandconnect.useanima.sh. The privileged channel reaches Anima and nothing else.content_scripts.matchesis<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.