Secret Store
Credential storage with categories, config resolution, encryption at rest, and output scrubbing.
Secret Store
Instance-level credential storage. Secrets are stored in a local database shared across all agents, resolved from config.toml via the secret: prefix, and injected into worker subprocesses based on their category. Values are scrubbed from tool output and status text, and are not included in LLM context by design.
Two Categories
Every secret has a category that controls subprocess exposure:
| Category | Subprocess Exposure | Use Case |
|---|---|---|
| System | Never exposed | LLM API keys, messaging tokens, webhook secrets |
| Tool | Injected as env vars | GH_TOKEN, NPM_TOKEN, AWS_ACCESS_KEY_ID |
All secrets are readable by the agent's internal Rust code via SecretsStore::get(). The category only determines whether the value is passed to worker subprocesses as an environment variable.
Auto-Categorization
When you add a secret without specifying a category, the store assigns one based on the name:
System (never exposed to subprocesses):
- LLM provider keys (
ANTHROPIC_API_KEY,OPENAI_API_KEY,OPENROUTER_API_KEY,GROQ_API_KEY,DEEPSEEK_API_KEY,XAI_API_KEY,MISTRAL_API_KEY,GEMINI_API_KEY, etc.) - Messaging adapter tokens (
DISCORD_BOT_TOKEN,SLACK_BOT_TOKEN,SLACK_APP_TOKEN,TELEGRAM_BOT_TOKEN,TWITCH_OAUTH_TOKEN, etc.) - Email credentials (
EMAIL_IMAP_USERNAME,EMAIL_IMAP_PASSWORD,EMAIL_SMTP_USERNAME,EMAIL_SMTP_PASSWORD) - Internal tool keys (
BRAVE_SEARCH_API_KEY) - Named adapter instance tokens -- any name matching
{PLATFORM}_{INSTANCE}_{FIELD}for a known adapter field (e.g.DISCORD_ALERTS_BOT_TOKEN,SLACK_SUPPORT_APP_TOKEN,TWITCH_GAMING_OAUTH_TOKEN)
Tool (everything else):
- Any unrecognized name defaults to Tool -- exposed to worker subprocesses as an environment variable
- Examples:
GH_TOKEN,NPM_TOKEN,AWS_ACCESS_KEY_ID,DOCKER_TOKEN,CARGO_REGISTRY_TOKEN
Auto-categorization is driven by the SystemSecrets trait. Each config section (LLM, messaging adapters, search integrations) declares its own credential fields. Adding a new adapter or provider automatically extends categorization without updating a central list.
You can override the auto-categorization by specifying a category explicitly when adding a secret.
Config Resolution
Any string value in config.toml supports three resolution modes:
secret:NAME → look up NAME in the secret store
env:VAR_NAME → read VAR_NAME from the system environment
anything else → literal valueThe secret: prefix is the recommended way to reference credentials in config:
[llm]
anthropic_key = "secret:ANTHROPIC_API_KEY"
openai_key = "secret:OPENAI_API_KEY"
[messaging.discord]
token = "secret:DISCORD_BOT_TOKEN"This keeps config.toml free of plaintext credentials. The secret store resolves references at config load time via a thread-local store reference.
Resolution Order
For LLM keys specifically, the resolution chain is:
config.toml value (secret: / env: / literal)
→ implicit env fallback (ANTHROPIC_API_KEY, etc.)
→ missingIf anthropic_key is set to "secret:ANTHROPIC_API_KEY" and the secret store has that key, it resolves to the stored value. If the store doesn't have it, the key is treated as missing and the implicit env fallback is tried.
Integration Setup
Tool secrets are the authentication layer for external integrations. The typical setup flow:
- Create a credential from the external service (e.g. a GitHub personal access token)
- Store it as a tool secret using the name the CLI tool expects (e.g.
GH_TOKENforgh) - Install a matching skill from skills.sh that teaches workers how to use the tool
See Skills — Setting Up an Integration for a complete walkthrough.
How Secrets Reach Subprocesses
Tool-category secrets are injected into worker subprocesses as environment variables. The flow:
Worker calls shell("npm publish")
→ Sandbox.wrap() builds the subprocess command
→ SecretsStore.tool_env_vars() returns Tool-category secrets
→ Each secret is injected via --setenv (bubblewrap) or Command::env()
→ Subprocess sees NPM_TOKEN in its environment
→ System-category secrets (ANTHROPIC_API_KEY, etc.) are NOT injectedThis works alongside environment sanitization. The subprocess starts with a clean environment -- no inherited variables from the parent process. Only the safe baseline variables (PATH, HOME, USER, LANG, TERM, TMPDIR), passthrough_env entries, and tool secrets are present.
Worker-Created Secrets
Workers have a secret_set tool that lets them store credentials directly into the secret store. This enables autonomous workflows where a worker creates accounts, generates API keys, or obtains tokens that should persist for future use.
Worker creates a GitHub bot account
→ Worker calls secret_set(name: "GH_TOKEN", value: "ghp_abc...")
→ Secret is stored with auto-categorized category (tool)
→ All future workers see GH_TOKEN in their environmentThe tool accepts an optional category parameter to override auto-categorization. If omitted, the same rules as the API apply -- known internal credentials are categorized as system, everything else as tool.
Browser Tool Integration
The browser_type tool supports a secret parameter for typing sensitive values (passwords, tokens, API keys) into web form fields without exposing them in tool arguments, output, or LLM context.
Task says "sign in with password from GH_PASSWORD"
→ Worker calls browser_type(index: 2, secret: "GH_PASSWORD")
→ Secret store resolves "GH_PASSWORD" to its stored value
→ Value is typed into the password field via CDP
→ Tool output: "Typed secret 'GH_PASSWORD' into element at index 2"
→ The actual password never appears in tool arguments or resultsThis is the only safe way to enter credentials in the browser. The text parameter types values in plain text that appears in tool output and LLM context -- never use it for passwords or tokens. Both tool-category and system-category secrets can be resolved via the secret parameter, since the value is used internally by the browser tool and never exposed to subprocesses.
If a task references an environment variable for a credential (e.g. "use password from $GH_PASSWORD"), the secret should be stored in the secret store under that name and passed via the secret parameter. Do not shell out to echo or otherwise resolve credential values into plain text.
Encryption at Rest
The store has two modes:
Unencrypted (Default)
Secrets are stored as plaintext in a redb database. All secret store features work -- categories, env injection, output scrubbing, config resolution. Only encryption at rest is missing.
This is the default because it requires zero setup. The store is functional immediately.
Encrypted (Opt-In)
AES-256-GCM encryption with a master key derived via Argon2id. The master key lives in the OS credential store, never on disk:
| Platform | Credential Store | Isolation |
|---|---|---|
| macOS | Keychain (Security framework) | Access controlled by code signature -- worker subprocesses can't retrieve the key |
| Linux | Kernel keyring (keyctl syscalls) | Workers are spawned with a fresh empty session keyring via pre_exec |
| Other | Not available | Encryption cannot be enabled |
Encryption Lifecycle
Unencrypted (default)
→ enable_encryption() → Unlocked (encrypted at rest, secrets readable)
→ lock() → Locked (secrets unreadable, store sealed)
→ unlock(password) → Unlocked
→ rotate_key() → Unlocked (new key, all secrets re-encrypted)States:
| State | Reads | Writes | Description |
|---|---|---|---|
| Unencrypted | Yes | Yes | Plaintext storage, no master key |
| Unlocked | Yes | Yes | Encrypted at rest, master key cached in memory |
| Locked | No | No | Encrypted, master key evicted -- all operations return an error |
When encryption is enabled:
- A random 16-byte salt is generated and stored in the database
- The password is derived into a 256-bit key via Argon2id (memory=64MB, iterations=3, parallelism=4)
- All existing secrets are re-encrypted with unique 12-byte nonces
- A sentinel value is encrypted and stored -- used to validate the key on unlock without decrypting every secret
- The master key is stored in the OS credential store
Retrieving the Master Key
If you need to retrieve the master key after encryption (e.g., you didn't copy it during setup), you can read it directly from the OS credential store:
macOS:
security find-generic-password -s "sh.spacebot.master-key" -a "instance" -wLinux:
The key is stored in the kernel keyring under the description sh.spacebot.master-key:instance. Use keyctl to search for it:
keyctl search @s user "sh.spacebot.master-key:instance"
# returns the key ID, then read it:
keyctl print <key_id>API
All endpoints operate on the instance-level secret store. No agent scoping is needed -- secrets are shared across all agents.
CRUD
| Method | Endpoint | Description |
|---|---|---|
GET | /api/secrets | List all secrets (names, categories, timestamps -- not values) |
PUT | /api/secrets | Add or update a secret |
DELETE | /api/secrets/{name} | Remove a secret |
GET | /api/secrets/{name}/info | Get metadata for a specific secret |
PUT body:
{
"name": "GH_TOKEN",
"value": "ghp_abc123...",
"category": "tool"
}If category is omitted, auto-categorization assigns one based on the name.
Store Status
GET /api/secrets/statusReturns the current store state, secret count, encryption status, and category breakdown.
Encryption
| Method | Endpoint | Description |
|---|---|---|
POST | /api/secrets/encrypt | Enable encryption (returns master key) |
POST | /api/secrets/unlock | Unlock with password |
POST | /api/secrets/lock | Lock the store (evict master key) |
POST | /api/secrets/rotate | Generate new key, re-encrypt all secrets |
Migration
POST /api/secrets/migrateScans config.toml for literal (plaintext) key values in known credential fields. For each one found:
- Stores the value in the secret store with the appropriate category
- Replaces the literal value in
config.tomlwith asecret:NAMEreference - Writes the updated
config.tomlto disk
Scanned fields are driven by SystemSecrets trait implementations -- the same declarations used for auto-categorization. This covers:
- All
[llm]provider keys [defaults]search keys- Default messaging adapter tokens (e.g.
[messaging.discord].token) - Named adapter instance tokens in
[[messaging.*.instances]]arrays (e.g.DISCORD_ALERTS_BOT_TOKENfor an instance named"alerts")
Values already using env: or secret: prefixes are skipped. This is a one-shot operation -- run it once after setting up the secret store to migrate existing plaintext credentials.
Export / Import
| Method | Endpoint | Description |
|---|---|---|
POST | /api/secrets/export | Export all secrets as JSON (values included) |
POST | /api/secrets/import | Import secrets from a JSON export |
Export returns all secrets with their plaintext values, categories, and timestamps. The store must be unlocked (or unencrypted) to export. Import merges secrets into the store -- existing secrets with the same name are updated.
Output Protection
Secret values are protected from appearing in tool output and LLM context through two layers:
Output Scrubbing
The StreamScrubber performs exact-match redaction of all stored secret values across tool output. It handles chunk boundaries -- if a secret value spans two output chunks, it's still detected and redacted.
All tool secrets and system secrets are registered with the scrubber. When a match is found, the value is replaced with [REDACTED].
Leak Detection
Regex-based pattern matching scans for secrets that may not be in the store. This catches secrets that were never added to the store but appear in output (e.g., hardcoded in source files). Detected patterns include API key formats for major providers, PEM private keys, and encoded variants (base64, URL-encoded, hex).
See Sandbox -- Leak Detection for the full list of detected patterns.
The two layers run in sequence: scrubbing first (exact match), then leak detection (pattern match). This prevents stored secrets from triggering false-positive leak detection kills.
On-Disk Layout
~/.spacebot/data/
└── secrets.redb # redb database with three tables:
├── secrets # name → value (plaintext or nonce+ciphertext)
├── secrets_metadata # name → JSON (category, timestamps)
└── secrets_config # encryption flag, argon2 salt, sentinelThe secrets database is instance-level -- a single store shared across all agents. It is separate from the main spacebot.db SQLite database. It uses redb for single-writer, lock-free reads.
On first startup after upgrading from a per-agent store layout, the bootstrap process automatically migrates secrets from any legacy ~/.spacebot/agents/{id}/data/secrets.redb files into the instance-level store.
Configuration
The secret store requires no configuration in config.toml. It initializes automatically at instance startup, before config loading, and is shared across all agents.
To use stored secrets in config, replace literal values or env: references with secret: references:
[llm]
anthropic_key = "secret:ANTHROPIC_API_KEY"
openai_key = "secret:OPENAI_API_KEY"
[messaging.discord]
token = "secret:DISCORD_BOT_TOKEN"
[messaging.telegram]
token = "secret:TELEGRAM_BOT_TOKEN"The secret: prefix works anywhere env: works. Both can coexist in the same config -- some keys from the store, others from environment variables.
Relationship to Other Systems
| System | Relationship |
|---|---|
| Sandbox | Injects tool secrets into sandboxed subprocesses. Environment sanitization ensures only tool-category secrets reach workers. |
| Configuration | secret: prefix in config values resolves from the store at load time. Migration endpoint converts plaintext config to secret: references. |
| Permissions | Permissions control which tools agents can use. The secret store controls which credentials those tools receive. Complementary layers. |
passthrough_env | Forwards env vars from the parent process. Redundant when using the secret store -- credentials should be stored in the store instead. Both work simultaneously. |