Skip to main content

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 kind in secrets. agent.Loader.Load decrypts every owner_type='agent' row of secrets into Agent.Secrets at boot (internal/agent/loader.go:360); a vault entry stored there would sit decrypted in process memory beside the Slack token. The secrets table also carries triggers (0074, 0077) that bump the workspace credential revision on github_pat writes, and a hard-delete query with no tombstone. None of that is what the vault wants. internal/vault imports internal/secrets for Encrypt/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 (the SecretResolver that hands the GitHub PAT to the workspace authorizer) does not learn about vault entries; the vault tools do not receive a SecretResolver. A test in internal/vault asserts that its sqlc query file references no table but agent_vault_entries and agent_vault_drops, so the firewall is a build-time fact rather than a convention.
Why share the master key at all, then? Because 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 existing secrets 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. upsertEncryptedSecret stores RedactedHint(plaintext) — the first 6 bytes of an unsalted SHA-256 of the secret — in redacted_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. normalizeKey takes 32 bytes as the key and SHA-256s anything else, with no stretching, and the key’s fingerprint sits in secret_keys as 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

One row per secret, not one JSON blob per agent. The blob is not simpler: each value has to be sealed separately anyway (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 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:
  1. The agent calls vault_request {name, description}. Mesh creates an agent_vault_drops row anchored to the current conversation and the agent’s outgoing message, and returns PUBLIC_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): …”
  2. 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.
  3. 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 into agent_vault_entries with source='drop'. The page confirms and shows nothing it was given.
  4. Mesh wakes the agent: CreateRun + AddRunTrigger anchored to the agent’s request message, with a prepareVaultDrop runtime 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 calls POST /api/vault-drops/{token}/renew, which in one transaction marks the old drop superseded_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.
Why renewal is safe to leave unauthenticated: the worst a stranger holding an expired link can do is make the agent post a new link into the same thread the old one was already in, to the same people who could already see it. The stranger does not receive the new link. What they could do is make the agent post it repeatedly, so renewal is bounded: only the latest drop in a lineage can renew, a lineage renews at most five times, and renewals share the public routes’ throttle. Those bounds are the whole defense and they are enough.

Not for crawlers

The dashboard has never had a robots.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. 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_from is 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_act action fill_secret: {ref, secret}. Validation mirrors type (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 by webcap/catalog), typed over CDP, and discarded; a text or key beside secret is refused at validation so the typed path cannot carry a value. Stamps last_used_at.
  • workspace_exec parameter secrets: [name, …] (at most 16): each named entry is opened through effects.Runtime.Vault for the run’s agent and added to the command’s environment as MESH_SECRET_<NAME> through the same grants file the run’s credentials use (workspace.Handle.WithEnv, lifted onto effects.EnvGranter by the app’s workspace adapter). The grants file is rewritten when its digest changes, so a later workspace_exec naming different secrets re-delivers without a new sandbox. The entries are withdrawn from the grant file when the command returns, so a later workspace_read_file of .mesh/grants.env sees 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 through workspace_credential_issuances — that table’s kinds are a CHECK of github_pat/gitlab_pat and 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 next workspace_exec naming it gets the new value or an error.
The prompt names the tools in the existing 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 no vault_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:
  1. Store and operator surface. Migration, internal/vault (HKDF subkey, seal, open, list, upsert, delete, the no-other-tables test), rekey/verify-secrets coverage with an integration test on a scratch database, the agent routes, the Secrets page, vault_list/vault_forget, robots.txt and X-Robots-Tag. After this an operator can hand an agent a secret and the agent can see that it has it.
  2. 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.
  3. 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.
Each PR carries its own 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. normalizeKey accepts 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 to internal/secrets and rekey.CheckMasterKey, independent of the vault.
  • Salting the existing hints. redacted_hint for secrets rows 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 /api route, 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