Two ways an agent fills a password field. On the left, fill with the literal value hunter2! — the secret flows into the transcript, the model's context, and the run log, all in red. On the right, fill_secret with the key name ACME_LOGIN_PASSWORD — only the name is in context, the value goes straight to the page, and a later snapshot of the field reads (secret).

Give an AI agent a browser and a job, and sooner or later it has to log in. So it does the obvious thing: it reads the page, finds the password field, and fills it — with the password, as a literal string, in the tool call. That call is the transcript. The transcript is what gets written to disk, cached by the framework, and sent to the model provider on every turn for the rest of the session. The password is now in all three places, and not one of them is where you meant to put it.

It gets worse the instant the agent checks its work. To confirm the field is filled, it reads the page back — and a naive browser tool reports a field by its value, because that is the tidiest handle it has. The password that went out in one call comes straight back in the next, and now it is in the transcript twice. Redacting the fill does not save you; the read undoes it.

You can't scrub what you shouldn't have collected

The usual reflex is to mask it after the fact — grep the logs for the secret and star it out. That is a filter bolted over a leak, and filters miss: a URL-encoded copy, a value split across a JSON boundary, the same secret typed into a differently named field next week. A value only stays out of the places it should never be if it never enters them. Which means the thing writing the transcript — the model — should not see it at all.

So don't hand the model the password. Hand it the name of the password. The secret lives in a file the agent cannot read into its context; the agent passes a key, and the resolving happens one layer down, in the browser, where the value goes onto the page and nowhere else.

// the leak: the literal secret is the argument, so it is in the transcript
await fill('#password', 'hunter2-the-actual-password');
// the fix: the argument is a NAME. the value never reaches the model.
await fillSecret('#password', 'ACME_LOGIN_PASSWORD');

fillSecret reads the value that key points at in a credentials file — ~/.config/rebel-studios/creds.env by default, or wherever TB_CREDS says — and types it into the page. The value never appears in the tool's reply, and every snapshot after it shows the field as (secret) rather than its contents. The file is read, never written, and never sent anywhere but the page you pointed it at.

The first version leaked it anyway

The obvious implementation types the secret and then, like every other fill, reports the field's new value so the agent can confirm it landed. That confirmation is the value — back into the transcript we were trying to keep it out of. We only caught it because we went looking at the reply. Now a test fails if a secret ever turns up in a tool's output, so the leak can't creep back in during a refactor. The general lesson: a secret path needs a test that asserts the absence of the secret, because absence is the one thing a code review reads right past.

Naming an element by its value is a side channel

There is a quieter version of the same bug. Ask a browser tool “what is on the page” and many will label a field by its current contents. For a search box that is fine; for a filled password it is the leak wearing a different hat, because the snapshot is context too. thinbrowser names elements by their label and never by their value, for exactly this reason — a value can be a secret, and it treats it as one until told otherwise.

What this does not do

This keeps the secret out of the transcript, the model's context, and your run logs. It does not hide the password from the site you are signing into — of course it doesn't; logging in means the server receives it, that is the entire point. And it is only as safe as the file it reads from: a credentials file the agent's own shell can cat is one the agent can leak another way, so keep it out of the repo, lock the permissions, and store only what the job needs. fill_secret narrows the blast radius to the one place a login has to reach. It does not make a password stop being a password.

We built this in on purpose

We make thinbrowser, a browser an AI agent drives through MCP. Agents log into things constantly, so a credential that stays out of the model's context is not a nice-to-have — it is the difference between an automation you would run against a real account and one you would not. Point TB_CREDS at your credentials file, give the agent the key name, and the password reaches the page and stops there.

# the value lives in the file; the agent only ever names the key
TB_CREDS=~/.config/secrets.env npx -y thinbrowser

The safest secret to give an automation is the one it never has to hold. That’s the kind of thing we work out at Rebel Studios.