Personal vault
The vault is an account-owned core module: src/vault and the Account → Vault section (web/modules/account/VaultSection.tsx, VaultItemUsage.tsx). It lets a human supply credentials without putting their values in the model's context. It is personal only: no Project or team vault. The native Vault tool lists metadata; API writes use ordinary account rights.
Ownership and data
src/vault/vaultService.ts is the only entry for account routes, the native tool and the tool layer. VaultService wraps users.vault; only values(userId, used), consumed by the tool layer, returns plaintext. List and mutation methods return metadata only. The module does not log values.
elowen.account_vault_items (user_id, name, description, created_at, updated_at, last_used_at) stores non-secret metadata. Values use the existing AES-256-GCM secret store with authenticated associated data (AAD): scope core-account, namespace vault, key equal to the item name. Plugin secret APIs cannot read core-account. Metadata and value writes share one transaction; account deletion removes both.
elowen-plugin-shared/vaultPlaceholders owns validation and placeholder grammar for storage, API and browser consumers:
- Names match
^[A-Z][A-Z0-9_]{0,63}$. - Descriptions contain 1–500 Unicode code points; API input is trimmed.
- Values contain at least four Unicode code points and at most 64 KiB in UTF-8 bytes; values are not trimmed. The minimum prevents exact-value scrubbing from erasing ordinary short text.
The account API family /auth/me/vault accepts only the authenticated caller's account, never another user id. GET returns { items: [{ name, description, updatedAt, lastUsedAt }] } in name order; POST creates, PATCH /:name changes the description, PUT /:name/value replaces a value, DELETE /:name removes an item and GET /:name/stats?from&to returns its uses per UTC day (see Usage). No response returns a value. These are ordinary account rights, not administrator-only routes. Chatbot accounts receive 403; unavailable encrypted storage returns 503 vault_unavailable, not an empty list.
Account and grants
The vault account is resolved at execution from the verified currentIdentity(): ordinary turns use elowenUserId, so a guest in a shared personal conversation uses the guest's vault, owner chat and cron use their acting account, and platform rooms/direct chats use the verified linked sender. A delegated identity uses its host-validated sharedConversation.writerUserId when present; otherwise it uses currentContributionUserId(), the inherited delegating account. This deliberately differs from currentAccountUserId(), which continues to resolve the conversation's environment account (HOME, Project and process ownership). Unlinked platform senders, missing account identities and chatbot accounts have no vault. Dream (memory consolidation) runs receive neither the tool nor the layer.
src/vault/vaultTool.ts supplies the no-parameter native Vault tool. It lists NAME: description lines, never values. Its description is:
Write {{vault:NAME}} in any tool argument; the value is inserted at execution and never shown. In Bash, put the placeholder inside double quotes, never single quotes.
Migration 0036_vault_grants.mjs appends Vault once to existing positive-id human accounts with any concrete Memory tool. It updates matching preset grants and revisions too, so a later preset push retains access; chatbot/system rows and instance settings remain unchanged. It also drops stored Address/React grants from those users and presets, since both tools need no account grant. Other accounts need an explicit grant.
The Vault tool grant also authorizes substitution. A placeholder without that grant refuses with Vault is not available to this account. Scrubbing runs whenever the acting account has items, independently of the grant. Minimal test wiring without the vault service composes neither the tool nor its layer.
Tool execution boundary
src/vault/vaultToolLayer.ts plugs into CapabilitySpec.wrapRawTool, the innermost raw-tool composition seam. It runs after deny, permission and Plan gates, argument reason stripping and the journal write, inside the plugin tools.call.after observer. Permission prompts, saved “always allow” patterns and the journal therefore see placeholders, not substituted values.
Every raw tool is wrapped except model-only interaction tools (AskUserQuestion, ExitPlanMode, ShowView, Address, React), code mode's exec/wait envelopes and ToolSearch. Code mode's nested raw calls are wrapped normally.
Each call reads the acting account's items fresh once, without a cache. A replacement or deletion applies to the very next call, not retroactively to an already running one. Every string argument is eligible for substitution. An unknown name fails that read with vault item NAME does not exist (VaultItemNotFoundError) before anything executes.
CORE_LITERAL_ARGUMENT_TOOLS in src/brain/toolMetadata.ts contains only core Memory tools. Plugin tools declare provides.literalArgumentTools: exact names from their own provides.tools whose arguments store or forward future model instructions. The loader validates names and live ownership, warns on undeclared entries, and passes the merged registry set through the spawner to the raw vault layer. The bundled Subagent declares Delegate, DelegateContinue, WorkflowStart and WorkflowAddNodes (WorkflowStart stores its title for the later model-visible completion; node tasks come from a file); registry Cronjob declares CronAdd and ScheduleWakeup so saved job and wake-up prompts never acquire substituted values. Registry Todo declares TaskCreate and TaskUpdate (task text returns as turn context), Skills declares CreateSkill (the body becomes future instructions) and Voice Bot declares VoiceCall (the prompt instructs a voice agent).
These tools keep every argument literal, including unknown placeholders, with output/error/live-update scrubbing only and no per-call secret environment. They do not need the Vault grant merely to preserve text; ordinary tool permissions still apply. A later executing child or scheduled turn resolves its own authorized tool use against its acting account. No declaration means ordinary substitution; the field grants neither read-only status nor Plan admission. The existing coordinated core floor stays unchanged: older cores accept but ignore the field without substituting vault references, and the first vault-capable core ships literal-argument handling together. See manifest contributions.
For example, the agent calls Vault to discover API_KEY: Access to the example service, then writes {{vault:API_KEY}} in an authorized tool argument. It never receives the credential in the listing.
Usage
The tool layer counts each item's uses through the vault module's own seam, in the shape API tokens keep their daily counters. After the grant check of a substituting call, and before the raw tool runs, wrapVaultTool reads the values with VaultService.values(account, names), naming every item the call references, in arguments and in a Bash command alike. In the same owner-locked transaction as that read, users.vault.values (C5-356) adds one use per named item to account_vault_usage_days for the current UTC day and advances last_used_at. A use therefore always lands on the item whose value the call receives, even when Account → Vault deletes the item, or deletes and recreates it under the same name, while the call runs. A call counts once per item however often it names the item, and it counts even when the tool then fails, because the value was handed over. Literal-argument tools, calls without placeholders and calls that are only scrubbed read with no names and record nothing. Only the count is stored: no value, argument or tool name.
A failed usage write fails the read, and with it the tool call, before any value is substituted, as an API token's request is counted before it is served (countRequest in the auth middleware). The counters therefore never miss a call that received a value, and the agent sees the error rather than a silently uncounted use. An unknown name fails the read with vault item NAME does not exist and counts nothing.
Counters cascade with their item and its account, so a recreated name starts with none. Account → Vault shows the last use on each folded item and reads GET /auth/me/vault/:name/stats only while an item is unfolded, over the window rules every stats route shares (apiTokenStatsQuerySchema, at most 366 days). The cost is one short statement per substituting call, inside the read's existing owner lock.
Shell launch and clients
Bash is not an ordinary plaintext argument substitution. Its command retains placeholders until Terminal launches it. Terminal rewrites a placeholder to ${VAULT_NAME} for sh on the server, in Sandbox and on Linux/macOS computers, or ${env:VAULT_NAME} for PowerShell on Windows computers. Values enter the environment of that one process, never its launch arguments. The managed Sandbox transports them through its stdin secret bootstrap.
Launch consumers read ctx.vaultEnvironment(), the VAULT_<NAME> map for the current call only; outside such a call it is empty. This is separate from agentShellEnvironment(), which never carries credentials. A launch path merges the map into that process's environment and adds its values to Sandbox's existing output-redaction mechanism (also used for GitHub tokens), using «vault:NAME» markers. This keeps handed-through values out of Terminal's stored output and process panel, not just out of the model-facing result.
Paired computer process-start messages carry env. The client must announce x-elowen-client-env: 1 (ticket capability envV1); otherwise execution refuses with LOCAL_CLIENT_OUTDATED and asks for a Chetty CLI update. Paired clients update with the server release.
Scrubbing and limits
There is one exact-value scrubber at the vault tool layer. It replaces each value, longest first, with «vault:NAME» in result text, details, thrown error messages and every live update, including Bash's live tail. It does not decode encodings, alter images or rewrite disk files. Cost is one fresh account read per call plus substitution and exact matching over argument/output strings, and one counter write per substituting call; there is no extra model call.
Literal handling covers only the declared tools. An argument the agent stores through a general writing tool (Write, Edit) or a raw ElowenApi route receives the substituted value, like any other argument. A prompt saved that way carries the real value into every later run: for example, a cron job body sent straight to the cronjob API (PUT /plugins/cronjob/jobs/:id) is stored with the value and handed to the model at each run. This is an accepted limit. Schedules should be created with CronAdd or ScheduleWakeup, which keep the placeholder.
The agent can create, replace or delete items through ElowenApi when the user asks it to, subject to the existing tool grant, permission and writable account-credential boundary. /auth/me/vault has no session-only restriction. If the user supplies a value in chat for the agent to save, that value has already passed through the user's message, tool arguments, transcript and provider request. The vault protects it only from that point on; saving it does not remove earlier copies. To keep a value out of chat, enter it in the Account → Vault form.
The guarantee is accidental chat disclosure prevention, not resistance to a deliberately exfiltrating agent. The complete user-facing limits, including rolling-tail cut edges and external writes, are in Security and the Vault guide.