close
Spacebot

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:

CategorySubprocess ExposureUse Case
SystemNever exposedLLM API keys, messaging tokens, webhook secrets
ToolInjected as env varsGH_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 value

The 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.)
  → missing

If 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:

  1. Create a credential from the external service (e.g. a GitHub personal access token)
  2. Store it as a tool secret using the name the CLI tool expects (e.g. GH_TOKEN for gh)
  3. 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 injected

This 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 environment

The 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 results

This 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:

PlatformCredential StoreIsolation
macOSKeychain (Security framework)Access controlled by code signature -- worker subprocesses can't retrieve the key
LinuxKernel keyring (keyctl syscalls)Workers are spawned with a fresh empty session keyring via pre_exec
OtherNot availableEncryption 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:

StateReadsWritesDescription
UnencryptedYesYesPlaintext storage, no master key
UnlockedYesYesEncrypted at rest, master key cached in memory
LockedNoNoEncrypted, master key evicted -- all operations return an error

When encryption is enabled:

  1. A random 16-byte salt is generated and stored in the database
  2. The password is derived into a 256-bit key via Argon2id (memory=64MB, iterations=3, parallelism=4)
  3. All existing secrets are re-encrypted with unique 12-byte nonces
  4. A sentinel value is encrypted and stored -- used to validate the key on unlock without decrypting every secret
  5. 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" -w

Linux:

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

MethodEndpointDescription
GET/api/secretsList all secrets (names, categories, timestamps -- not values)
PUT/api/secretsAdd or update a secret
DELETE/api/secrets/{name}Remove a secret
GET/api/secrets/{name}/infoGet 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/status

Returns the current store state, secret count, encryption status, and category breakdown.

Encryption

MethodEndpointDescription
POST/api/secrets/encryptEnable encryption (returns master key)
POST/api/secrets/unlockUnlock with password
POST/api/secrets/lockLock the store (evict master key)
POST/api/secrets/rotateGenerate new key, re-encrypt all secrets

Migration

POST /api/secrets/migrate

Scans config.toml for literal (plaintext) key values in known credential fields. For each one found:

  1. Stores the value in the secret store with the appropriate category
  2. Replaces the literal value in config.toml with a secret:NAME reference
  3. Writes the updated config.toml to 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_TOKEN for 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

MethodEndpointDescription
POST/api/secrets/exportExport all secrets as JSON (values included)
POST/api/secrets/importImport 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, sentinel

The 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

SystemRelationship
SandboxInjects tool secrets into sandboxed subprocesses. Environment sanitization ensures only tool-category secrets reach workers.
Configurationsecret: prefix in config values resolves from the store at load time. Migration endpoint converts plaintext config to secret: references.
PermissionsPermissions control which tools agents can use. The secret store controls which credentials those tools receive. Complementary layers.
passthrough_envForwards env vars from the parent process. Redundant when using the secret store -- credentials should be stored in the store instead. Both work simultaneously.

On this page