Agent vault
Status: the store and operator surface (step 1, #470) and the use-sites —fill_secret in the browser, workspace_exec secrets: in the sandbox, the
prompt guidance (step 3) — are built; drop links (step 2) are in #472. This document is the design for letting
an agent hold secrets a human gave it — a Sentry token, a website login, an API key for a
service Mesh has no connector for — and use them without reading them, in the
browser and in the sandbox. It also specifies the one-time drop link through
which a human hands a secret to an agent without the value ever touching Slack.
Two kinds of secret, two firewalls
Mesh already holds secrets, and none of them are the agent’s to read:
The first store is infrastructure: it exists so the agent does not need the
secret. The agent asks for a Linear issue and the MCP transport adds the bearer
token; the agent never learns it, and the token is useless to an injected prompt.
That property is the whole reason connectors exist, and the vault must not erode
it. Two consequences:
- The vault is a separate table with a separate code path, not a new
kindinsecrets.agent.Loader.Loaddecrypts everyowner_type='agent'row ofsecretsintoAgent.Secretsat boot (internal/agent/loader.go:360); a vault entry stored there would sit decrypted in process memory beside the Slack token. Thesecretstable also carries triggers (0074,0077) that bump the workspace credential revision ongithub_patwrites, and a hard-delete query with no tombstone. None of that is what the vault wants.internal/vaultimportsinternal/secretsforEncrypt/Decrypt/Fingerprint— the crypto and the master key are shared, the rows are not. - No vault tool can reach
secrets, and no infrastructure path can reach the vault.effects.Runtime.Secrets(theSecretResolverthat hands the GitHub PAT to the workspace authorizer) does not learn about vault entries; the vault tools do not receive aSecretResolver. A test ininternal/vaultasserts that its sqlc query file references no table butagent_vault_entriesandagent_vault_drops, so the firewall is a build-time fact rather than a convention.
MESH_AGENT_MASTER_KEY is the
one credential an operator holds personally, and docs/secrets-and-recovery.md
is the runbook for it. A second key is a second runbook, a second thing to lose,
and a second thing a backup is useless without. The blast radius the user wants
separated is who can read the plaintext at runtime, and that is a code-path
property, not a key property. rotate-key and verify-secrets are extended to
cover agent_vault_entries in the same transaction and the same report, so the
existing drill stays one drill.
The vault does not use the master key directly, though. It derives its own
key with HKDF-SHA256 (info = "mesh/vault/v1") and seals with that. Same
master key, same runbook, same rotation — rotate-key re-derives from the next
key — but a vault ciphertext handed to the secrets decrypt path, or the
reverse, fails authentication instead of opening. The firewall is then also a
fact about the bytes, not only about which function was called.
Two things the existing scheme does that the vault must not copy
The existingsecrets crypto is sound: AES-256-GCM from the standard library,
a fresh 96-bit random nonce per seal, the key normalized to 32 bytes, key
fingerprints that name a key without revealing it, the key itself nowhere near
the database. Nothing in it needs a third party, and nothing in it should.
Two details are fine for API tokens and wrong for the passwords a vault holds:
- The hint is a hash of the plaintext.
upsertEncryptedSecretstoresRedactedHint(plaintext)— the first 6 bytes of an unsalted SHA-256 of the secret — inredacted_hint, beside the ciphertext, and uses it as the GCM AAD. For a 40-character API token that is harmless. For a human password it is a 48-bit offline oracle that needs no master key: an attacker with a dump tests guesses at SHA-256 speed, and two agents holding the same password have the same hint. The vault’s AAD is the row’s identity,vault:<agent_id>:<name>, which is what AAD is for (a ciphertext moved to another row fails to open) and reveals nothing about the value. The vault has no hint column. - A short master key is hashed once, unsalted.
normalizeKeytakes 32 bytes as the key and SHA-256s anything else, with no stretching, and the key’s fingerprint sits insecret_keysas a check oracle. A master key generated as the runbook says — 32 random bytes — is unaffected. A memorable passphrase would be brute-forceable from a dump. That is a property of the master key, not of the vault, and the fix (an entropy floor at boot, or HKDF with a stored salt) is a small separate change noted in What is not built.
Use by reference, not by value
The default assumption — “the agent asks for a secret and gets the value” — is the wrong primitive, for a reason specific to Mesh: everything that enters the model context is persisted in plaintext.model_calls.request_body holds the
exact bytes sent to OpenRouter (0080), prompt snapshots hold the system prompt,
tool_observations hold tool results, and memory extraction reads the
transcript. A revealed value is therefore written to Postgres several times, sent
to a third-party model provider, and eligible to be remembered. Slack is the
exposure the drop link avoids; the ledger is a larger one.
So the vault is designed so that the common cases never reveal a value. The
agent names the secret and Mesh applies it at the boundary where it is needed:
The browser case is the one that motivates the feature and it is already half
built.
browser_act refuses password fields today (internal/webbrowser/page.go:48,
if(e.type==='password')return {error:'password'}), with a tool description
that says an operator can import authenticated state instead. fill_secret is
the action that hole was left for: it lifts the password-field refusal only
for a value Mesh types itself. Once the agent has logged in, the existing
agent-owned profile (browser:profile in secrets, Playwright storage-state,
restored over CDP on every new session — internal/webbrowser/profile.go:79)
keeps the cookies, so the next run does not need the password again. Neither
Cloudflare Browser Run nor Browserbase offers credential injection; they offer
cookie persistence, which Mesh already drives. Server-side fill is the correct
layer and the vendor-neutral one.
Second factors are a bounded follow-on: an entry flagged totp holds a seed,
and fill_secret on it inserts the current code. Not in the first delivery.
The data
secrets.Encrypt is one plaintext,
one nonce, one AAD), a blob needs read-modify-write under a lock for every
write, and it cannot carry per-entry provenance, last_used_at, or a
per-entry delete. The “data bag” the agent perceives is the set of its rows.
name is constrained to an environment-variable-safe identifier because it
becomes one in the sandbox. Writes replace in place (ON CONFLICT (agent_id, name)); the vault keeps no value history, for the same reason secrets keeps
none — a history of secrets is a larger secret.
The drop link
The agent cannot receive a secret through Slack: the message persists in Slack’s own store, in the conversation graph, and in the prompt. So the agent asks for it out of band:- The agent calls
vault_request {name, description}. Mesh creates anagent_vault_dropsrow anchored to the current conversation and the agent’s outgoing message, and returnsPUBLIC_URL/drop/<token>and the expiry. The agent includes the link in its reply: “I’ll need a Sentry auth token for this. Drop it here (link expires in 15 minutes): …” - A human opens the link.
GET /api/vault-drops/{token}returns the agent’s display name, the secret’s name and description, who asked for it, and when the link expires — enough for the human to know what they are handing over and to whom. It does not consume the token: Slack’s link unfurler and every corporate link scanner issue a GET, and a link that dies on preview is a link that never works. - The human submits.
POST /api/vault-drops/{token}with{value}runs, in one transaction:UPDATE agent_vault_drops SET consumed_at = now() WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()(of two racing submits exactly one gets the row), then the encrypted upsert intoagent_vault_entrieswithsource='drop'. The page confirms and shows nothing it was given. - Mesh wakes the agent:
CreateRun+AddRunTriggeranchored to the agent’s request message, with aprepareVaultDropruntime hook that puts “the secret<name>you requested is now in your vault” into the turn — the same pattern schedules and commitments use (internal/schedule/scheduler.go:205,internal/app/runtime.go:384). The agent’s next reply lands in the originating thread and says, in the thread, that it received<name>. That sentence is load-bearing; see below.
Two clocks: fifteen minutes to submit, three days to come back
A link that works for fifteen minutes is the right exposure for a bearer token in a channel and the wrong experience for a human. People get pulled into a meeting, go home, come back Monday. So a drop has two clocks:expires_at, fifteen minutes. Until then the link accepts a value.renewable_until, seventy-two hours. Between the two, opening the link shows “this link has expired” and a single button: Send me a new link. Pressing it callsPOST /api/vault-drops/{token}/renew, which in one transaction marks the old dropsuperseded_at, creates a new drop in the same lineage with fresh clocks, and wakes the agent the same way a submission does — with a notice that says “the drop link for<name>expired and the person asked for a new one; here it is: …”. The agent posts the new link in the original thread, through the original connector. The human’s next message in Slack or Telegram is the new link, exactly where the old one was.- After
renewable_until, and for any superseded or consumed token, the link is a 404 indistinguishable from a token that never existed.
Not for crawlers
The dashboard has never had arobots.txt; nothing in it is linkable from
the open web, so nothing has asked. The drop link is the first Mesh URL that
will be pasted into chat tools that unfurl, forward, and index. Two additions,
both install-wide because nothing Mesh serves should be indexed:
GET /robots.txt answering User-agent: * / Disallow: /, and
X-Robots-Tag: noindex, nofollow added to securityHeaders
(internal/app/app.go:1170). Neither is a security control. They are
politeness toward crawlers that honor them, and the token’s real protection is
that it is single-use, short-lived, and hashed at rest.
The implementation is the invite flow (internal/admin/operators.go:447-514,
handleGetInvite/handleAcceptInvite) with a different payload: 32 bytes from
crypto/rand, base64url; only the SHA-256 stored; unknown, expired, and consumed
all answer the same 404; Cache-Control: no-store; Origin must match host on the
POST; the shared per-source throttle (internal/admin/throttle.go) with an empty
account key; {token} already scrubbed from the activity log (audit.go:149).
The SPA path /drop/{token} joins spaPaths (internal/app/app.go:1182) and the
React route sits beside /invite/:token inside AuthShell. The existing CSP,
Referrer-Policy: no-referrer, and frame-ancestors 'none' apply unchanged.
What the link does and does not protect against
The link is a bearer credential posted into a channel. It is unauthenticated on purpose — requiring a Mesh login would exclude exactly the colleague the agent is helping — so be precise about the threat:- Interception (someone else reads the thread and opens the link first). Mitigated by the fifteen-minute submit window and single use. The legitimate human then sees a 404 and knows something is wrong, and the agent’s in-thread confirmation names the moment it happened.
- Poisoning (someone submits a token for their account, so the agent acts
in a place the attacker controls or leaks the task’s data there). This is the
real risk, and it is why the agent’s confirmation is posted in the thread: a
secret arriving that the requester did not send is visible to the requester.
consumed_fromis recorded for the audit trail. A deployment that wants stronger assurance can require that drops be opened by a signed-in operator; that is a flag, not a redesign, and is not in the first delivery. - Exfiltration of the value through the drop itself. The value goes from the human’s browser to Mesh over TLS and into ciphertext. It is never echoed, never logged (the admin middleware logs the path with the token scrubbed and never the body), never in a URL.
- Replay against the model. The drop is not a Slack message, so the value is not in the conversation graph or the prompt. The prompt sees only the wake-up notice naming the entry.
tools.md says a human handoff must not become a private modal — awaiting_input
is an attributed thread message anyone can answer. The drop link keeps that
property where it matters: the request and the receipt are both thread
messages anyone in the thread can see. Only the value takes the side channel,
because the value is the one thing that must not be a thread message.
The tools
The vault verbs reach the turn’s registry through the dynamic provider set (tool.Provider, the door remote MCP and hosted web tools already use):
vault.Service implements Definitions(ctx, Scope) and is appended to the
engine’s DynamicTools beside them, scoped to the agent the trigger was
authenticated for. It is a provider rather than a new field on turn.Engine
because Engine is copied by value into every builder method, and growing it
is a cost paid forty times a turn. The consequence is that the verbs are part
of the progressive-activation index for agents with many integrations — they
carry a vault package hint so tool_search finds them — rather than being
unconditionally in every prompt. They are Internal: true — they write only
storage Mesh owns — except vault_request, which is an effect because it sends
a link into the world and must cross the commit boundary once.
Plus the two use-site extensions that are not tools of their own:
browser_actactionfill_secret:{ref, secret}. Validation mirrorstype(input or textarea, writable, attached, unobscured) without the password-field refusal, which stays in force for every other action. The value is opened in the browser runtime (webbrowser.Service.WithVault, wired bywebcap/catalog), typed over CDP, and discarded; atextorkeybesidesecretis refused at validation so the typed path cannot carry a value. Stampslast_used_at.workspace_execparametersecrets: [name, …](at most 16): each named entry is opened througheffects.Runtime.Vaultfor the run’s agent and added to the command’s environment asMESH_SECRET_<NAME>through the same grants file the run’s credentials use (workspace.Handle.WithEnv, lifted ontoeffects.EnvGranterby the app’s workspace adapter). The grants file is rewritten when its digest changes, so a laterworkspace_execnaming different secrets re-delivers without a new sandbox. The entries are withdrawn from the grant file when the command returns, so a laterworkspace_read_fileof.mesh/grants.envsees the run’s grants and not a secret an earlier command asked for; a withdrawal that fails is reported on the call. Redaction covers the run’s credential grants by key and every per-command value at any length — a one-character vault value makes redaction noisy, never leaky. Vault entries do not go throughworkspace_credential_issuances— that table’s kinds are a CHECK ofgithub_pat/gitlab_patand its revision machinery exists to invalidate a sandbox when the operator changes a grant; a vault entry is the agent’s own and is delivered on the agent’s own request. Rotating or forgetting an entry does not tear down a running sandbox; the nextworkspace_execnaming it gets the new value or an error.
Tools: line, and a vault section
(prompt.vaultSection, selected by Context.Vault the way the coding section
is selected by CodeWork: offered now or activatable this turn) tells the agent
what the vault is for: check vault_list first; use a secret through
fill_secret or secrets:, never by asking a human to paste it into the
thread; never write a value into a reply, a file the user will read, a commit,
or a log; when a value is needed that the vault lacks, say so in the thread and
ask for it the way the offered tools allow — which is vault_request once drop
links are offered.
Reveal
There is deliberately novault_reveal. Every case that has been named — log in
to a website, call an API from the sandbox — is served by a use-site that keeps
the value out of model context, and a tool that returns the value would put it
in model_calls.request_body, in prompt snapshots, in tool observations, in
memory extraction’s view of the transcript, and on the wire to the model
provider. If a real case arrives that none of the use-sites can serve — an MCP
tool argument is the likely shape — the design is: the run records the values
it revealed, the ledger replaces exact occurrences with [REDACTED:vault:<name>]
before persisting, the tool description says plainly that the value still
reaches the model provider, and the tool is a per-agent opt-out. That is a
bounded addition when it is needed, and it is not needed now.
Operator surface
The operator can see what an agent holds, remove it, and add to it — the authenticated path to the same rows — but can never read a value back.
All responses
Cache-Control: no-store. The dashboard gets a Secrets
SectionCard on the agent page linking to /agents/:slug/vault, a page
built from Card/Label/Input/Button/Dialog that lists entries with
their provenance and last use, an “Add secret” dialog, per-row delete, and the
open-drops list. The public drop page is web/src/pages/Drop.tsx beside
Invite.tsx: fetch metadata, show agent and secret name, one password-type
input, submit, confirm. Both routes are added to docs/operator-api.md in the
feature section, the index, and the public-routes paragraph, and to the
throttled-routes list.
Delivery
Three pull requests, each useful alone:- Store and operator surface. Migration,
internal/vault(HKDF subkey, seal, open, list, upsert, delete, the no-other-tables test),rekey/verify-secretscoverage with an integration test on a scratch database, the agent routes, the Secrets page,vault_list/vault_forget,robots.txtandX-Robots-Tag. After this an operator can hand an agent a secret and the agent can see that it has it. - Drop links.
agent_vault_drops,vault_request, the three public routes,/drop/{token}with its expired-and-renewable state, the wake-up run,prepareVaultDrop, docs. After this the agent can ask a human for a secret in a thread without the value crossing Slack, and the human can come back on Monday. - Use-sites.
fill_secret(lifting the password refusal for Mesh-typed values),workspace_exec secrets:, redaction generalized to the handle’s env, prompt guidance. Built; see the use-site table above.
docs/ update; this file becomes normative as the
pieces land and the “Status” line at the top changes.
What is not built
vault_reveal. See Reveal. Waits for a case no use-site serves.- TOTP entries. A seed the browser fill turns into a current code.
- Operator-only drops. A per-install flag requiring a signed-in operator to open a drop link, for deployments that want poisoning ruled out rather than made visible.
- A master-key entropy floor.
normalizeKeyaccepts any string; the runbook says 32 random bytes and nothing enforces it. A boot-time refusal of a key under, say, 16 bytes of decoded entropy is a small change tointernal/secretsandrekey.CheckMasterKey, independent of the vault. - Salting the existing hints.
redacted_hintforsecretsrows is an unsalted 48-bit hash of the plaintext. Harmless for the API tokens that table holds today; worth replacing with a row-identity AAD the next time that table’s envelope changes, because it becomes a weakness the day a low-entropy value is stored there.
Design guardrails
- do not store vault entries in
secrets— the loader decrypts that table’s agent rows at boot, and the two stores must have different readers - do not give a vault tool a
SecretResolver, and do not give the effects runtime a vault handle — the firewall is who holds which decrypt path - do not return a vault value from any
/apiroute, ever, to anyone - do not consume a drop on GET — link previews are GETs
- do not let a drop accept a value for more than fifteen minutes, and do not let a human hit a dead end for three days — renewal posts a new link into the same thread, bounded per lineage
- do not hash a vault plaintext into anything stored — the AAD is the row’s identity, and there is no hint column
- do not seal vault rows with the raw master key — the HKDF subkey is what makes the two stores’ ciphertexts mutually unreadable
- do not make reveal the default — the ledger persists model context in plaintext, so the use-sites exist to keep values out of it
- do not hard-code the sandbox redaction list — redact every delivered value
- do not route the drop’s value through the conversation graph — the wake-up notice names the entry, never the value
- do not add a second master key for the vault — one key, one runbook, two tables, both rotated in one transaction