Secrets the agent can use but never see
The owner’s passwords, tokens and keys live in the vault beside buddi’s own. An agent can ask for one to be used somewhere; it never gets to read it. This page is how that works: the bindings that say where a value may go, the plugins that deliver it, the scrubber that keeps it out of every text that leaves core, and the Settings page where the owner manages it all.
1. The problem
Section titled “1. The problem”Without a place for the owner’s own keys, a site password is the last manual
step in every bank check, and a developer agent that needs an admin password
and an API token for a project has only bad places to put them: the
workspace’s .env, the chat, the agent’s memory. Each of those is somewhere an
agent reads. The rule that no agent handles a password holds for every kind of
secret, not only site logins.
Three rules hold throughout:
- There is no read path. No tool, area or page returns a value.
- The approval shows where it goes. The card names the destination the destination itself checked, never what the agent claimed.
- MFA stays the owner’s unless the owner, per secret, chooses otherwise (§4, TOTP).
2. A secret is a value plus bindings
Section titled “2. A secret is a value plus bindings”A secret is a name (“PNC password”), a value, and one or more bindings. A binding says where the value may go:
- a destination kind, registered by a plugin (§3);
- a target, the exact place within that kind, checked by the destination;
- an approval rule: every time, first time only, or pre-approved.
A secret with no binding can be stored and cannot be used. A use that matches no binding is refused before any card is drawn. Each kind states the loosest rule it allows; the owner can always pick a stricter one.
3. Destinations
Section titled “3. Destinations”A destination is declared by a plugin through the host API
(plugin-host-api.md §4.2): kind, checkTarget,
describe, deliver, maxRule. Every kind is named <plugin>.<what>, after
the plugin that answers for it; the registry enforces it. Core finds the
binding, asks the destination to check the target against the live world,
applies the rule, reads the vault and calls deliver. The value exists in core
and in that one deliver call, and nowhere else.
| Kind | Registered by | Target | Loosest rule |
|---|---|---|---|
browser.field |
browser (extension and Playwright backends) | exact origin (scheme, host, port), or a wildcard origin | pre-approved |
http.header |
core’s http area |
exact host and header name, HTTPS only | pre-approved |
http.url |
core’s http area (1.9) |
the plugin that stored it and the exact host, HTTPS and GET only | pre-approved |
http.basic |
core’s http area (1.26) |
the plugin that stored it and the host (exact, or *. a domain), HTTPS, WebDAV verbs only |
pre-approved |
developer.env |
developer, for start and run |
workspace and variable name | pre-approved per workspace |
browser.native.type |
browser (macOS accessibility) | the app’s bundle id | every time |
browser.form.data |
browser | exact or wildcard origin, and field | every time, every use logged |
<plugin>.account |
the plugin that owns the account | its account id | pre-approved |
- Browser field fill. The agent calls
secret.fill { name, ref }. The backend reports the origin of the frame holding the field, not the top page and not the agent’s claim. The tool reads the field the ref names and picks the kind from the field and from what the owner bound. A password-marked field isbrowser.field. A visible field isbrowser.fieldtoo when the owner bound the secret asbrowser.fieldto that field’s origin, so a username goes in beside its password, and the card names the field (“the Username field on https://auth.wikimedia.org”). Any other visible field isbrowser.form.data. So a password field never takes a form-data-only secret, and a card number bound as form data always goes through the every-time destination. A TOTP secret (§4) fills any field the ref names, because an authenticator field is rarely marked as a password. The extension uses the debugger’s insertText, Playwright itsfill. The result says “filled”.secret.listtells the agent which names it may use and where each may go (kinds and targets, never a value), so it never has to ask the owner for a name. - Wildcard origins. A site that signs in on a sister host (Wikipedia’s
page is
en.wikipedia.org, its sign-inauth.wikimedia.org) is one binding, not two: abrowser.fieldorbrowser.form.dataorigin may behttps://*.wikimedia.org. The scheme is exact, the port is exact when given, and*.stands for one or more labels at the left of a fixed suffix, so it matchesauth.wikimedia.organda.b.wikimedia.orgbut notwikimedia.orgitself. A*anywhere else, a bare*, and*.on a public suffix (*.com,*.co.uk,*.github.io,*.pages.dev) are refused. The browser plugin bundles a short list of public suffixes taken from the Public Suffix List and never fetches it. The card, the use log anddescribename the real origin the field sits on, never the pattern. - HTTP request header. A plugin passes
auth: { secret: name }toctx.buddi.http.request; core inserts the header after the host check. For API tokens. - A private address. Some links are the credential: a calendar’s secret
ICS address carries its token in the path. A plugin stores the link the
owner typed on its page with
secrets.put, bound tohttp.urlwith{ plugin, host }, and fetches it withauth: { secret, as: 'url' }naming only the host. Core reads the value, checks it is HTTPS on that host and passes the address rules, and sends one GET; the plugin never holds the link, and no other plugin can fetch it. The calendar plugin is the first. - A sign-in password. A CalDAV account signs in with a user name and an
app-specific password. A plugin stores the password with
secrets.put, bound tohttp.basicwith{ plugin, host }(the host exact, or*.a domain such as*.icloud.com, whose accounts live on numbered hosts), and asks withauth: { secret, as: 'basic', username }. Core buildsAuthorization: Basicitself, for GET, HEAD, OPTIONS, PROPFIND, REPORT, PUT and DELETE only, a body of at most 256 KiB, an answer of at most 10 MB and 120 requests a minute per secret; the plugin keeps the user name and never holds the password. The calendar plugin’s CalDAV accounts are the first. - Process environment.
developer.startanddeveloper.rundeliver every binding for their workspace into the child’s environment, subject to each rule; the card on first use names the variables and the command. The value is never written to a file by buddi. - Native app typing.
secret.type { name }types into the focused field of the bound app. Weaker: an app’s text field can show what was typed, and the accessibility API can only sometimes tell a secure field. Always a card. - Form data. Card, account and tax numbers, into a named field on a bound origin. Always a card, and every use is logged with the field.
The extension backend is acceptable for fills: the value crosses a loopback WebSocket to buddi’s own paired extension, and the card shows the origin the extension checked. No other route reaches the owner’s signed-in Chrome.
4. Uses
Section titled “4. Uses”- Site logins.
browser.field, pre-approvable per origin: the username and the password are two secrets bound to the same origin, and the agent finds their names withsecret.list. - API tokens.
http.headerfor plugins;developer.envfor code the agent is writing. - Developer environment variables.
developer.env, which may be pre-approved per workspace, because the value only ever reaches the owner’s own processes. A project’s admin password and API token are two secrets bound to that workspace’sADMIN_PASSWORDandAPI_TOKEN, and its.envholds neither.developer.writeanddeveloper.editrefuse content that contains a stored value, whatever the file, and say to bind it instead. The file is never the test: agents write.envfiles with ordinary configuration all the time, and they keep doing so. - Plugin account credentials. The email IMAP and SMTP passwords, and the
model accounts’ credentials. They live in the owner vault as secrets bound
to
<plugin>.account, pre-approved because the owner typed them on that plugin’s own page, and they appear in Settings like any other. This kind is the exception to “never held”: an IMAP connection keeps its password for hours, sodeliverhands the value to the plugin’s process for as long as its connection lives, and the use is recordedheld. What protects it there is the scrub (§5) and the cleared environment (host API §6), not the no-read rule. The card and the Settings row say so in one line. - Database and service connection strings.
developer.envfor a workspace’sDATABASE_URL;<plugin>.accountfor a plugin’s own service. - SSH and git credentials. The design holds them as a
developer.gitkind targeting a remote URL, delivered throughGIT_ASKPASSor an agent socket. It is not registered: the developer plugin has no push. - TOTP seeds. A secret may be marked TOTP; its value is the seed and
what is delivered is the current code, into the
browser.fielddestination only. It merges the password and the second factor into one thing buddi holds, so it is the owner’s explicit choice per secret, off by default, with that sentence on the setting. Every code generated is logged.
5. Output scrubbing
Section titled “5. Output scrubbing”Every text that leaves buddi’s core for a model, a log, the canvas,
Activity or Telegram is scrubbed for every stored value, and each match is
replaced by ‹secret:NAME›. buddi’s own keys are scrubbed the same way
under their own names. This is not a second line behind the destinations;
it is how a process that prints its environment, a page that echoes a
field, or an error that quotes a header stays safe.
One scrubber in core, packages/core/src/secrets/scrub.ts, applied at the
choke points every path already passes through:
ToolRegistry.invokeandexecuteApproved: the tool result and the error, before either is returned or recorded, andexecuteApproved’s recorded result. This covers process output (developer.output), page text (browser.act,web.read), every tool result and every thrown message.appendEvent: every event payload, so Activity, the canvas, the transcript and the Telegram relay, which all read events, see only the scrubbed form. The transcript rows (core.messages) are scrubbed as they are written too: they are what a surface reads and what a backup takes.- The runtime loop, on the request assembled for the provider: the last step before a model, catching an owner message with a pasted secret.
ctx.buddi.logand the gateway’s log sink.- The memory area and
createProposal, so nothing scrubbed elsewhere is kept in a note or a skill.
A page query’s answer is scrubbed as well (packages/gateway/src/web/pages.ts),
the same choke point as a tool result: a developer workspace’s .env that a
value was written into before the rule answers with the marker. The scrubber is
primed once at boot (createWiringAsync), so the synchronous sinks — a
plugin’s buddi.log, the serve loops — scrub from the first line.
Matching. One Aho-Corasick automaton over each value and its common
encodings: exact, URL-encoded (both %20 and +), JSON-escaped, and
base64 in both alphabets at each of the three byte alignments (the stable
middle of the encoding, so a secret inside a longer base64 blob still
matches). One pass over the text, linear in its length, whatever the number
of secrets. The automaton is rebuilt when a secret is saved, renamed or
deleted. Values shorter than eight characters match only on token
boundaries, so a four-digit PIN does not blank every year in a page.
Screenshots are images and are not scrubbed; a filled password field shows
dots, and §3’s native typing is always a card for that reason.
6. Settings → Keys and secrets
Section titled “6. Settings → Keys and secrets”An entry, Keys and secrets, in the Models and access group, after Model accounts and Computer & browser. It sits there because it answers the same question as its neighbours, what agents may reach, and not You’s, which is about the owner and what buddi learned about them.
The page is Plugins-shaped: “Settings ›”, the title, one line, and Add a secret on the right. One panel holds the secrets in groups, each a short heading, hairlines between them; a group with nothing in it is not drawn.
- Your secrets — the owner’s own, the ones agents fill through
secret.fill. - Mail — mailbox passwords. A new one is set on the Email page (its row’s
Set password form,
#/settings/p.email.settings?account=<id>&set=password), which tests the login before it keeps anything. - One group per plugin that keeps secrets — Calendar links for the
calendar plugin’s private links and CalDAV passwords (an
http.urlorhttp.basicbinding names its plugin). - Model accounts and Connections — read-only here; each row links to where it is managed.
What holds a secret is read by the gateway (GET /api/secrets answers
usedBy per secret): the mailbox whose row names it, the model account whose
secret_ref is it, the connection whose token or program variable it is.
A row is a glyph for its kind, a human name — the mailbox’s provider and “app password”, the calendar’s name, the model account’s label with its provider beside it, the owner’s own name — and one line on where it may go and when it was last used: “Used by the mailbox sam@gmail.com · Last used 5 minutes ago”, “Filled on pnc.com · asks you the first time”, “Sent only to calendar.google.com”. No stored name, id or binding kind is on the row; they sit under Details in the row’s sheet, beside the usage history in words. A healthy row has no pill.
A problem is one sentence and its one fix:
- “Held back 23 seconds ago: it was asked for at uploads.github.com, where it may not go.” A refusal is buddi’s own: the use was not bound to that place (§4), the secret had no value, the vault was locked, or a TOTP secret was asked for outside a browser field. buddi declined to hand the value over; the value never left. Fix: Change where it may go (for the owner’s own secrets).
- “No value stored.” — the vault holds no entry for it (after a restore, say). Fix: Set a value, or Set password for a mailbox.
- “Gmail turned it down at sign-in 12 minutes ago.” — the mail server refused the password at login (the Email page’s own record). Fix: Set password.
- “Didn’t go through …” — buddi handed the value over and the destination
could not take it. “Waiting for your approval …” — a
first-timeorevery-timeuse waits on its card. - “Can’t be used anywhere until you choose where it may go.” — the owner’s secret has no binding yet.
Not used by anything. A secret is unused when nothing holds it and nothing
can reach it: every binding is a held kind (email.account,
accounts.provider, mcp.env) whose mailbox, account or connection no longer
names it, or a kind no installed plugin registers — or it has no binding and
its name is one buddi generated for a row, or the old .env mailbox password
GMAIL_APP_PASSWORD (a mailbox the old .env named still counts as using
it). The row says so quietly and offers Remove; nothing is removed by
itself. Model accounts and Connections, managed on their own pages, never
offer Remove here. Only a confirmed absence counts: when the lookup of what
holds secrets fails (a timeout, a lost connection — a table that is not
installed is an absence), no secret it could hold is called unused; the row
says “Couldn’t check what uses it just now.” instead (usageUnknown). An
owner’s own secret with no binding is “not usable yet”, not unused.
The ⋯ menu (a sheet from the bottom on a phone): for the owner’s own, Replace value, Rename, Change where it may go, Usage history, then Delete, which asks once and names what stops working. A mailbox’s offers Set a new password and Usage history; a plugin’s, Replace value and Usage history. A secret’s name and places that a mailbox, account or plugin keeps are theirs to change.
- Add: name, value (a password field), TOTP (off), and the places it may go, each a kind of place, the place and when it asks you; a new place starts at “the first time” where the kind allows it.
- Changing where a value may go to a looser rule or a new place is the owner’s own action on the page, never a tool.
- The value is never shown again after save. “Replace value” is the only way to change it.
- On save, buddi looks for the value where it may already be: events,
the transcript, and memory notes and preferences, skipping a place that is
not installed. It reports each place found and offers to scrub them, one
tap. Learned skills are clean by construction (a proposal’s payload is
scrubbed when it is created, before it can become a skill). The files under
a bound workspace are the developer plugin’s to scan, and its
developer.writeanddeveloper.editrefuse a stored value from then on. - buddi’s own keys are folded under the panel, read-only, by name. They cannot be bound or replaced from here.
- Every write is an
ownerOnlytool: no model sees it, as with email’s add-account.
7. Storage
Section titled “7. Storage”The vault buddi already uses: the macOS keychain, or the file vault
elsewhere. The value is stored under owner-secret:<id>, so a rename never
touches the vault. Names, bindings and uses are rows, never values:
core.secrets: id, name (unique), totp, created_at, updated_at.core.secret_bindings: secret, kind, target jsonb, rule, first approved at.core.secret_uses: secret, kind, target, agent, conversation, action, outcome, at.
held uses are swept after 30 days. A credential read by its own plugin (a
mail poll, a model call) is one row each, thousands a week; the hourly
proposal loop deletes held rows older than a month. Delivered, pending,
refused and failed rows are the owner’s audit log and are never swept.
What plugins and accounts hold. The email plugin’s per-mailbox entries
(secretNameFor(address); the old .env mailbox’s GMAIL_APP_PASSWORD is
renamed to its account’s own name when that mailbox is adopted) are
owner secrets bound to email.account, and the plugin never copies them into
process.env. Provider account credentials (the Anthropic and Codex
secretRef entries) are owner secrets bound to accounts.provider. The
gateway reads a provider account’s credential through a recorded
accounts.provider use on the run path; the configured computation on
reload reads the vault directly, because a use row per account per reload is
noise, and nothing outside the gateway’s own code reaches it. The OAuth
adapters (Codex, Claude) keep their secret-name API and read through a
translating vault: the names they ask for resolve onto the owner secrets, so a
refresh lands under the same owner-secret:<id> without the adapters learning
the storage scheme. buddi’s own keys (KNOWN_SECRETS) stay under their names,
are not bindable, and are scrubbed.
An installation that kept these credentials the old way is migrated once at start. The migration is idempotent and deletes an old entry only after the new one reads back.
8. Threats
Section titled “8. Threats”- A phishing origin. The binding is an exact origin, compared by the
destination with the origin the backend reports for the field’s own
frame. A look-alike host, one spelled in punycode, or the right site in a
frame on the wrong one is refused before any card. The card shows the
checked origin, so the owner is never the check. A wildcard binding
(§3) keeps this: its suffix is fixed and never a public suffix, so
https://*.wikimedia.orgmatches only hosts that end in.wikimedia.org, which only Wikimedia can create. It widens the binding to every host the owner of that suffix runs, which the owner chose by typing the pattern. - A prompt-injected agent filling into the wrong field. The field must be on the bound origin; a password field takes only a secret bound to that origin; a use outside a binding is refused. A secret bound to an origin may go into a visible field there, which the site itself controls; what the page echoes back is scrubbed (§5). For kinds where a field could echo the value (native typing, form data), every use is a card showing the field.
- A process echoing its environment. Output scrubbing (§5) replaces the
value in
developer.output, the tool result and the event. The value is never written to.env, and writing it there is refused. - A plugin trying to read. There is no
get.deliverreceives a value only for a binding naming its own kind.createVaultis not reachable from a plugin (host API §6) and no secret stays inprocess.env. A plugin is in-process, so a hostile one could still reach the keychain itself; install approval is the guard, as for everything else a plugin could do.
9. End to end
Section titled “9. End to end”- The owner stores “PNC password” bound to
https://www.pnc.com, pre-approved; the Finance Advisor signs in without the value appearing in any tool result, event, log, model request or memory note. - The same fill on a look-alike origin is refused with no card.
- A developer workspace starts with its admin password and token from two
secrets; its
.envholds neither;developer.outputand a deliberateenvprint show‹secret:…›. - A tool result containing a stored value URL-encoded or base64-encoded reaches the model scrubbed.
- Mailboxes keep polling with no password in
process.env. - A TOTP secret delivers a code only after the owner turned TOTP on for it.
- The use log shows every use with agent, destination and target.
Related
Section titled “Related”packages/core/src/vault,packages/extension/src/commands.ts, browser.md, plugin-host-api.md.
