Summary
Add an encrypted secrets store that (a) consolidates the secrets currently spread across .env and
the SQLite config store, and (b) lets personas/subagents use and store secrets under access control
— without secret values ever entering the model's context.
Two kinds of secret, one store
- Infrastructure secrets (provider keys, bot token, email / CalDAV credentials): read by the
app's own code at boot or at the tool boundary; not persona-scoped.
- Agent secrets (e.g. a payment-provider key a finance persona uses, a stored session/token):
persona-scoped and accessed by reference.
Same encrypted table and master key; two access paths.
Core design — references, never plaintext
This generalises the existing tool_env pattern (auth injected at the executor; the model never sees
the value). Secrets are stored by name; a persona's prompt lists only the names it may use. When
the agent emits e.g. run_command("curl -H 'Authorization: Bearer {{secret:NAME}}' ..."), the
executor resolves the placeholder from the vault after checking the active persona's ACL, then
runs. Critical boundary: substitution happens only in the run_command path, never in message /
email bodies, so a prompt-injected agent cannot exfiltrate a secret through the messaging tools.
Plaintext never enters context, history, logs, or memory extraction.
Consolidation lever (incremental, reversible)
The config loader already does ${ENV_VAR} interpolation. Add a sibling resolver ${vault:NAME}
resolved from the unsealed vault at config-load time. Secrets can then be migrated one at a time — a
field can read from .env, the config store, or the vault, with .env still a fallback throughout.
No flag day.
Storage & master key
- One SQLite table of ciphertext; unsealed into process memory at boot and resolved from there.
- A single master key held in env / keyfile; everything else moves into the vault, collapsing the
on-disk plaintext surface to one value whose only job is to unseal the rest.
- Bootstrap note: the master key (and only it) must be available before unseal — avoids a
chicken-and-egg with the provider key / bot token needed to start.
Access control
- ACL is the persona's secret scope (the fourth Profile facet). Subagents inherit-never-widen.
- Each persona owns a private namespace
persona:<name>:* it may write; writing shared/global is an
ASK action. Where a secret originates in code (a browser session, an OAuth token), the sidecar
writes it to the vault directly so it never round-trips through the model.
UX & product
- Admin UI: a Secrets page listing secret names only (never values) with add / rotate /
revoke, and a persona × secret access matrix so it's obvious which persona can use what. Value
entry (add/rotate) is the only place a value is typed — write-only and masked. Responsive/touch-
friendly at phone width for quick checks, reusing consistent masked-field + table components.
- Setup wizard: generate-or-enter the master key, and a guided "import from .env" step that
detects existing secrets and offers to migrate them — consolidation as a one-screen action.
- On the go (Telegram): read-only / safe actions only — list secret names + which persona
holds access, and see recent-use audit; values are never entered or shown over Telegram
(error-prone on a phone, and the channel must stay clean). Sensitive edits are deep-linked to the
web UI.
- Mobile-first: the web Secrets page is responsive for quick on-the-go checks; value entry stays
web-only by design.
Phasing
- Store + master key +
${vault:...} resolver (consolidate existing infra secrets). Low risk.
- Persona-scoped reference / ACL layer on top (the security-sensitive part), once personas exist.
Threat-model note
Combining a secret store, outbound messaging, and untrusted web content reproduces the classic
prompt-injection trifecta. Primary mitigations: reference-only reads; substitution confined to
run_command; per-persona scoping; inherit-never-widen; ASK on shared-secret writes and new outbound
destinations; optional browser egress allowlist.
Acceptance criteria
- An existing infra secret (e.g. the email password) can be served from the vault via
${vault:...}
with .env fallback intact.
- A new user can set the master key and import existing
.env secrets from the setup wizard in one
pass.
- A persona can use a secret by reference in
run_command without the value appearing in context,
history, or logs.
- Secret names and access are viewable on mobile, but values are never entered or shown over
Telegram.
- A persona cannot read secrets outside its scope; a subagent cannot widen scope.
Related
- Backs: browser sessions, pi provider keys.
- ACL facet of: persona profiles.
Summary
Add an encrypted secrets store that (a) consolidates the secrets currently spread across
.envandthe SQLite config store, and (b) lets personas/subagents use and store secrets under access control
— without secret values ever entering the model's context.
Two kinds of secret, one store
app's own code at boot or at the tool boundary; not persona-scoped.
persona-scoped and accessed by reference.
Same encrypted table and master key; two access paths.
Core design — references, never plaintext
This generalises the existing
tool_envpattern (auth injected at the executor; the model never seesthe value). Secrets are stored by name; a persona's prompt lists only the names it may use. When
the agent emits e.g.
run_command("curl -H 'Authorization: Bearer {{secret:NAME}}' ..."), theexecutor resolves the placeholder from the vault after checking the active persona's ACL, then
runs. Critical boundary: substitution happens only in the
run_commandpath, never in message /email bodies, so a prompt-injected agent cannot exfiltrate a secret through the messaging tools.
Plaintext never enters context, history, logs, or memory extraction.
Consolidation lever (incremental, reversible)
The config loader already does
${ENV_VAR}interpolation. Add a sibling resolver${vault:NAME}resolved from the unsealed vault at config-load time. Secrets can then be migrated one at a time — a
field can read from
.env, the config store, or the vault, with.envstill a fallback throughout.No flag day.
Storage & master key
on-disk plaintext surface to one value whose only job is to unseal the rest.
chicken-and-egg with the provider key / bot token needed to start.
Access control
persona:<name>:*it may write; writing shared/global is anASK action. Where a secret originates in code (a browser session, an OAuth token), the sidecar
writes it to the vault directly so it never round-trips through the model.
UX & product
revoke, and a persona × secret access matrix so it's obvious which persona can use what. Value
entry (add/rotate) is the only place a value is typed — write-only and masked. Responsive/touch-
friendly at phone width for quick checks, reusing consistent masked-field + table components.
detects existing secrets and offers to migrate them — consolidation as a one-screen action.
holds access, and see recent-use audit; values are never entered or shown over Telegram
(error-prone on a phone, and the channel must stay clean). Sensitive edits are deep-linked to the
web UI.
web-only by design.
Phasing
${vault:...}resolver (consolidate existing infra secrets). Low risk.Threat-model note
Combining a secret store, outbound messaging, and untrusted web content reproduces the classic
prompt-injection trifecta. Primary mitigations: reference-only reads; substitution confined to
run_command; per-persona scoping; inherit-never-widen; ASK on shared-secret writes and new outbounddestinations; optional browser egress allowlist.
Acceptance criteria
${vault:...}with
.envfallback intact..envsecrets from the setup wizard in onepass.
run_commandwithout the value appearing in context,history, or logs.
Telegram.
Related