From 734b1b2711f958799f5f79031864c38f3c71cde0 Mon Sep 17 00:00:00 2001 From: James Pine Date: Fri, 27 Feb 2026 21:15:25 -0800 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20secret=20store=20=E2=80=94=20cred?= =?UTF-8?q?ential=20isolation,=20encryption=20at=20rest,=20output=20scrubb?= =?UTF-8?q?ing,=20worker=20secret=5Fset=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instance-level credential storage with system/tool categories, config resolution via secret: prefix, AES-256-GCM encryption with OS keystore (macOS Keychain / Linux kernel keyring), output scrubbing across all worker output paths, and a secret_set tool for autonomous credential management. Includes full API (CRUD + lifecycle), CLI subcommands, dashboard UI, platform master key injection, SystemSecrets trait for auto-categorization, Jinja-based worker prompt injection, and documentation. --- AGENTS.md | 11 + Cargo.lock | 25 + Cargo.toml | 5 + README.md | 25 +- docs/content/docs/(configuration)/config.mdx | 54 +- docs/content/docs/(configuration)/meta.json | 2 +- .../docs/(configuration)/permissions.mdx | 2 +- docs/content/docs/(configuration)/sandbox.mdx | 3 +- docs/content/docs/(configuration)/secrets.mdx | 299 ++++ docs/content/docs/(core)/agents.mdx | 4 +- docs/content/docs/(core)/architecture.mdx | 13 +- docs/content/docs/(deployment)/roadmap.mdx | 4 +- docs/content/docs/(features)/workers.mdx | 2 +- docs/content/docs/index.mdx | 2 +- interface/src/api/client.ts | 130 ++ interface/src/components/UpdateBanner.tsx | 94 - interface/src/components/UpdatePill.tsx | 27 + interface/src/router.tsx | 2 - interface/src/routes/Overview.tsx | 16 +- interface/src/routes/Settings.tsx | 662 ++++++- prompts/en/tools/secret_set_description.md.j2 | 1 + prompts/en/worker.md.j2 | 28 + src/agent/branch.rs | 8 + src/agent/channel.rs | 42 +- src/agent/cortex.rs | 9 + src/api.rs | 1 + src/api/mcp.rs | 6 - src/api/secrets.rs | 760 ++++++++ src/api/server.rs | 17 +- src/api/state.rs | 8 + src/config.rs | 362 +++- src/error.rs | 5 +- src/main.rs | 708 +++++++- src/opencode/worker.rs | 55 +- src/prompts/engine.rs | 14 +- src/prompts/text.rs | 3 + src/sandbox.rs | 86 +- src/secrets.rs | 3 +- src/secrets/keystore.rs | 306 ++++ src/secrets/scrub.rs | 28 + src/secrets/store.rs | 1572 ++++++++++++++++- src/tools.rs | 18 +- src/tools/secret_set.rs | 135 ++ src/tools/set_status.rs | 12 + tests/bulletin.rs | 22 + tests/context_dump.rs | 23 + 46 files changed, 5346 insertions(+), 268 deletions(-) create mode 100644 docs/content/docs/(configuration)/secrets.mdx delete mode 100644 interface/src/components/UpdateBanner.tsx create mode 100644 interface/src/components/UpdatePill.tsx create mode 100644 prompts/en/tools/secret_set_description.md.j2 create mode 100644 src/api/secrets.rs create mode 100644 src/secrets/keystore.rs create mode 100644 src/tools/secret_set.rs diff --git a/AGENTS.md b/AGENTS.md index 7489e84f8..1c321cbd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,17 @@ Single binary. No server dependencies. Runs on tokio. All data lives in embedded **Stack:** Rust (edition 2024), tokio, Rig (v0.30.0, agentic loop framework), SQLite (sqlx), LanceDB (embedded vector + FTS), redb (embedded key-value). +## JavaScript Tooling (Critical) + +- For UI work in `spacebot/interface/`, use `bun` for all JS/TS package management and scripts. +- **NEVER** use `npm`, `pnpm`, or `yarn` in this repo unless the user explicitly asks for one. +- Standard commands: + - `bun install` + - `bun run dev` + - `bun run build` + - `bun run test` + - `bunx ` (instead of `npx `) + ## Migration Safety - **NEVER edit an existing migration file in place** once it has been committed or applied in any environment. diff --git a/Cargo.lock b/Cargo.lock index c61624660..4801c6357 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,6 +241,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -6263,6 +6275,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -8405,6 +8428,7 @@ dependencies = [ "aes-gcm", "anyhow", "arc-swap", + "argon2", "arrow-array", "arrow-schema", "async-stream", @@ -8457,6 +8481,7 @@ dependencies = [ "rust-embed", "rustls 0.23.36", "schemars 0.8.22", + "security-framework 3.5.1", "semver", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 3dd65e045..aa4439734 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ notify = "7" # Cryptography (for secrets) aes-gcm = "0.10" sha2 = "0.10" +argon2 = "0.5" rand = "0.9" # UUID generation @@ -91,6 +92,10 @@ daemonize = "0.5" libc = "0.2" ignore = "0.4" +# OS keystore (macOS Keychain for master key storage) +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3" + # Discord serenity = { version = "0.12", default-features = false, features = ["client", "gateway", "model", "cache", "chrono", "rustls_backend"] } async-trait = "0.1" diff --git a/README.md b/README.md index c4a2213e1..fad6dd1e0 100644 --- a/README.md +++ b/README.md @@ -242,20 +242,35 @@ headers = { Authorization = "Bearer ${SENTRY_TOKEN}" } ### Security -Workers execute arbitrary shell commands and subprocesses on your behalf. Spacebot uses defense-in-depth to contain what those processes can do: +Spacebot runs autonomous LLM processes that execute arbitrary shell commands and spawn subprocesses. Security isn't an add-on — it's a layered system designed so that no single failure exposes credentials or breaks containment. -- **Process sandbox** — shell and exec tools run inside OS-level filesystem containment. On Linux, [bubblewrap](https://github.com/containers/bubblewrap) creates a mount namespace where the entire filesystem is read-only except the agent's workspace and any explicitly configured writable paths. On macOS, `sandbox-exec` enforces equivalent restrictions via SBPL profiles. No amount of LLM creativity can write outside the sandbox — it's kernel-enforced, not string-filtered -- **Workspace isolation** — file tools canonicalize all paths and reject anything outside the agent's workspace. Symlinks that escape the workspace are blocked +#### Credential Isolation + +Secrets are split into two categories: **system** (LLM API keys, messaging tokens — never exposed to subprocesses) and **tool** (CLI credentials like `GH_TOKEN` — injected as env vars into workers). The category is auto-assigned based on the secret name, or set explicitly. + +- **Environment sanitization** — every subprocess starts with a clean environment (`--clearenv` on Linux, `env_clear()` everywhere else). Only safe baseline vars (`PATH`, `HOME`, `LANG`), tool-category secrets, and explicit `passthrough_env` entries are present. System secrets never enter any subprocess +- **Secret store** — credentials live in a dedicated redb database, not in `config.toml`. Config references secrets by alias (`anthropic_key = "secret:ANTHROPIC_API_KEY"`), so the config file is safe to display, screenshot, or `cat` +- **Encryption at rest** — optional AES-256-GCM encryption with a master key derived via Argon2id. The master key lives in the OS credential store (macOS Keychain, Linux kernel keyring) — never on disk, never in an env var, never accessible to worker subprocesses +- **Keyring isolation** — on Linux, workers are spawned with a fresh empty session keyring via `pre_exec`. Even without the sandbox, workers cannot access the parent's kernel keyring where the master key lives +- **Output scrubbing** — all tool secret values are redacted from worker output before it reaches channels or LLM context. A rolling buffer handles secrets split across stream chunks. Channels see `[REDACTED]`, never raw values +- **Worker secret management** — workers can store credentials they obtain (API keys from account creation, OAuth tokens) via the `secret_set` tool. Stored secrets are immediately available to future workers + +#### Process Containment + +- **Process sandbox** — shell and exec tools run inside OS-level filesystem containment. On Linux, [bubblewrap](https://github.com/containers/bubblewrap) creates a mount namespace where the entire filesystem is read-only except the agent's workspace and configured writable paths. On macOS, `sandbox-exec` enforces equivalent restrictions via SBPL profiles. Kernel-enforced, not string-filtered +- **Dynamic sandbox mode** — sandbox settings are hot-reloadable. Toggle via the dashboard or API without restarting the agent +- **Workspace isolation** — file tools canonicalize all paths and reject anything outside the agent's workspace. Symlinks that escape are blocked - **Leak detection** — a hook scans every tool argument before execution and every tool result after execution for secret patterns (API keys, tokens, PEM private keys) across plaintext, URL-encoded, base64, and hex encodings. Leaked secrets in arguments skip the tool call; leaked secrets in output terminate the agent - **Library injection blocking** — the exec tool blocks dangerous environment variables (`LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, `NODE_OPTIONS`, etc.) that could hijack child process loading - **SSRF protection** — the browser tool blocks requests to cloud metadata endpoints, private IPs, loopback, and link-local addresses - **Identity file protection** — writes to `SOUL.md`, `IDENTITY.md`, and `USER.md` are blocked at the application level -- **Secret encryption** — credentials stored via the secrets system are encrypted at rest with AES-256-GCM +- **Durable binary storage** — `tools/bin` directory on PATH survives hosted rollouts. Workers are instructed to install binaries there instead of ephemeral package manager locations ```toml [agents.sandbox] mode = "enabled" # "enabled" (default) or "disabled" writable_paths = ["/home/user/projects/myapp"] # additional writable dirs beyond workspace +passthrough_env = ["CUSTOM_VAR"] # forward specific env vars to workers ``` --- @@ -505,6 +520,8 @@ No server dependencies. Single binary. All data lives in embedded databases in a | [Cortex](docs/content/docs/(core)/cortex.mdx) | Memory bulletin and system observation | | [Cron Jobs](docs/content/docs/(features)/cron.mdx) | Scheduled recurring tasks | | [Routing](docs/content/docs/(core)/routing.mdx) | Model routing and fallback chains | +| [Secrets](docs/content/docs/(configuration)/secrets.mdx) | Credential storage, encryption, and output scrubbing | +| [Sandbox](docs/content/docs/(configuration)/sandbox.mdx) | Process containment and environment sanitization | | [Messaging](docs/content/docs/(messaging)/messaging.mdx) | Adapter architecture (Discord, Slack, Telegram, Twitch, Webchat, webhook) | | [Discord Setup](docs/content/docs/(messaging)/discord-setup.mdx) | Discord bot setup guide | | [Browser](docs/content/docs/(features)/browser.mdx) | Headless Chrome for workers | diff --git a/docs/content/docs/(configuration)/config.mdx b/docs/content/docs/(configuration)/config.mdx index 80db479b1..90e271baf 100644 --- a/docs/content/docs/(configuration)/config.mdx +++ b/docs/content/docs/(configuration)/config.mdx @@ -167,18 +167,33 @@ agent_id = "main" channel = "webhook" ``` -## Environment Variable References +## Value References -Any string value in the config can reference an environment variable with the `env:` prefix: +Any string value in the config supports three resolution modes: + +| Prefix | Resolution | Example | +|--------|-----------|---------| +| `secret:` | Look up from the [secret store](/docs/secrets) | `"secret:ANTHROPIC_API_KEY"` | +| `env:` | Read from system environment variable | `"env:ANTHROPIC_API_KEY"` | +| _(none)_ | Literal value | `"sk-ant-..."` | ```toml -anthropic_key = "env:ANTHROPIC_API_KEY" +# From the secret store (recommended) +anthropic_key = "secret:ANTHROPIC_API_KEY" + +# From an environment variable +openai_key = "env:OPENAI_API_KEY" + +# Literal value (not recommended — use secret: or env: instead) +groq_key = "gsk_abc123..." ``` -This reads `ANTHROPIC_API_KEY` from the environment at startup. If the variable is unset, the value is treated as missing. +The `secret:` prefix resolves from the agent's secret store at config load time. If the secret doesn't exist, the value is treated as missing and implicit env fallbacks are tried. LLM keys also have implicit env fallbacks — if no key is set in the TOML, Spacebot checks `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `KILO_API_KEY`, and `OPENCODE_GO_API_KEY` automatically. +Use `POST /api/secrets/migrate` to automatically move plaintext credentials from `config.toml` into the secret store and replace them with `secret:` references. See [Secret Store -- Migration](/docs/secrets#migration) for details. + ## Env-Only Mode If no `config.toml` exists, Spacebot runs from environment variables alone: @@ -241,7 +256,7 @@ Most config values are hot-reloaded when their files change. Spacebot watches `c | Setting | Why | |---------|-----| -| LLM API keys | Provider clients are initialized once | +| LLM API keys | Provider clients are initialized once (applies to `secret:`, `env:`, and literal values) | | Messaging adapters (Discord token, webhook bind/port) | Adapter connections are long-lived | | Agent topology (adding/removing `[[agents]]`) | Databases and event buses are per-agent | | Database paths | Connections are opened once at startup | @@ -295,6 +310,7 @@ System prompts (channel, branch, worker, compactor, cortex, etc.) are Jinja2 tem │ ├── lancedb/ # vector search │ ├── config.redb # key-value settings │ ├── settings.redb # runtime settings (worker_log_mode, etc.) + │ ├── secrets.redb # secret store (categories, encryption) │ └── logs/ # worker execution logs └── archives/ # compaction transcripts ``` @@ -331,19 +347,19 @@ If you define a custom provider with the same ID as a legacy key, your custom co | Key | Type | Default | Description | |-----|------|---------|-------------| -| `anthropic_key` | string | None | Anthropic API key (or `env:VAR_NAME`) | -| `openai_key` | string | None | OpenAI API key (or `env:VAR_NAME`) | -| `openrouter_key` | string | None | OpenRouter API key (or `env:VAR_NAME`) | -| `kilo_key` | string | None | Kilo Gateway API key (or `env:VAR_NAME`) | -| `zhipu_key` | string | None | Zhipu AI (GLM) API key (or `env:VAR_NAME`) | -| `groq_key` | string | None | Groq API key (or `env:VAR_NAME`) | -| `together_key` | string | None | Together AI API key (or `env:VAR_NAME`) | -| `fireworks_key` | string | None | Fireworks AI API key (or `env:VAR_NAME`) | -| `deepseek_key` | string | None | DeepSeek API key (or `env:VAR_NAME`) | -| `xai_key` | string | None | XAI API key (or `env:VAR_NAME`) | -| `mistral_key` | string | None | Mistral API key (or `env:VAR_NAME`) | -| `opencode_zen_key` | string | None | OpenCode Zen API key (or `env:VAR_NAME`) | -| `opencode_go_key` | string | None | OpenCode Go API key (or `env:VAR_NAME`) | +| `anthropic_key` | string | None | Anthropic API key (`secret:NAME`, `env:VAR_NAME`, or literal) | +| `openai_key` | string | None | OpenAI API key (`secret:NAME`, `env:VAR_NAME`, or literal) | +| `openrouter_key` | string | None | OpenRouter API key (`secret:NAME`, `env:VAR_NAME`, or literal) | +| `kilo_key` | string | None | Kilo Gateway API key (`secret:NAME`, `env:VAR_NAME`, or literal) | +| `zhipu_key` | string | None | Zhipu AI (GLM) API key (`secret:NAME`, `env:VAR_NAME`, or literal) | +| `groq_key` | string | None | Groq API key (`secret:NAME`, `env:VAR_NAME`, or literal) | +| `together_key` | string | None | Together AI API key (`secret:NAME`, `env:VAR_NAME`, or literal) | +| `fireworks_key` | string | None | Fireworks AI API key (`secret:NAME`, `env:VAR_NAME`, or literal) | +| `deepseek_key` | string | None | DeepSeek API key (`secret:NAME`, `env:VAR_NAME`, or literal) | +| `xai_key` | string | None | XAI API key (`secret:NAME`, `env:VAR_NAME`, or literal) | +| `mistral_key` | string | None | Mistral API key (`secret:NAME`, `env:VAR_NAME`, or literal) | +| `opencode_zen_key` | string | None | OpenCode Zen API key (`secret:NAME`, `env:VAR_NAME`, or literal) | +| `opencode_go_key` | string | None | OpenCode Go API key (`secret:NAME`, `env:VAR_NAME`, or literal) | #### Custom Providers @@ -361,7 +377,7 @@ name = "My Provider" # Optional - friendly name for display |-------|------|----------|-------------| | `api_type` | string | Yes | API protocol type. One of: `anthropic`, `openai_completions`, `openai_chat_completions`, `openai_responses`, `gemini`, or `kilo_gateway` | | `base_url` | string | Yes | Base URL of the API endpoint. Must be a valid URL (including protocol) | -| `api_key` | string | Yes | API key for authentication. Supports `env:VAR_NAME` syntax to reference environment variables | +| `api_key` | string | Yes | API key for authentication. Supports `secret:NAME` and `env:VAR_NAME` syntax | | `name` | string | No | Optional friendly name for the provider (displayed in logs and UI) | > Note: diff --git a/docs/content/docs/(configuration)/meta.json b/docs/content/docs/(configuration)/meta.json index 7d3a7d6cb..ef915e0d0 100644 --- a/docs/content/docs/(configuration)/meta.json +++ b/docs/content/docs/(configuration)/meta.json @@ -1,4 +1,4 @@ { "title": "Configuration", - "pages": ["config", "sandbox", "permissions"] + "pages": ["config", "secrets", "sandbox", "permissions"] } diff --git a/docs/content/docs/(configuration)/permissions.mdx b/docs/content/docs/(configuration)/permissions.mdx index fc47315a8..4c6113cc1 100644 --- a/docs/content/docs/(configuration)/permissions.mdx +++ b/docs/content/docs/(configuration)/permissions.mdx @@ -50,7 +50,7 @@ But NOT: ``` ~/.spacebot/agents/{other_agent}/ # other agents' data -~/.spacebot/config.toml # instance config (contains API keys) +~/.spacebot/config.toml # instance config (secret references, not plaintext keys) /etc/, /home/, /Users/ # system paths ``` diff --git a/docs/content/docs/(configuration)/sandbox.mdx b/docs/content/docs/(configuration)/sandbox.mdx index 36d647c15..6f1cb38c3 100644 --- a/docs/content/docs/(configuration)/sandbox.mdx +++ b/docs/content/docs/(configuration)/sandbox.mdx @@ -116,7 +116,7 @@ passthrough_env = ["GH_TOKEN", "GITHUB_TOKEN", "NPM_TOKEN"] Each listed variable is read from the parent process environment at subprocess spawn time and injected into the worker's environment. Variables not in the list are stripped. -When the secret store is available, `passthrough_env` is redundant -- credentials should be stored in the secret store, which injects tool secrets automatically. The field is additive and continues to work alongside the store. +When the [secret store](/docs/secrets) is available, `passthrough_env` is redundant -- credentials should be stored in the secret store, which injects tool secrets automatically. The field is additive and continues to work alongside the store. ## Durable Binaries @@ -216,6 +216,7 @@ The sandbox is one layer in a defense-in-depth model: | **Exec env var blocklist** | Blocks `LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, etc. | Exec tool | | **Leak detection** | Regex scan of all tool output for secret patterns | All tools via SpacebotHook | | **Output scrubbing** | Exact-match redaction of known secret values | Worker output, status updates, OpenCode events | +| **[Secret store](/docs/secrets)** | Categorized credential storage, config resolution, tool secret injection | All agents | | **Permissions system** | Application-level tool access control | All tools | The sandbox and permissions system are complementary. The [permissions system](/docs/permissions) controls which tools an agent can use and what paths the LLM is allowed to access at the application level. The sandbox enforces filesystem boundaries at the kernel level for subprocesses that are allowed to run. diff --git a/docs/content/docs/(configuration)/secrets.mdx b/docs/content/docs/(configuration)/secrets.mdx new file mode 100644 index 000000000..958e80889 --- /dev/null +++ b/docs/content/docs/(configuration)/secrets.mdx @@ -0,0 +1,299 @@ +--- +title: Secret Store +description: 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 never appear in logs, tool output, or LLM context. + +## 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 value +``` + +The `secret:` prefix is the recommended way to reference credentials in config: + +```toml +[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. + +## 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](/docs/sandbox#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. + +## 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: + +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:** + +```bash +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: + +```bash +keyctl search @s user "sh.spacebot.master-key:instance" +# returns the key ID, then read it: +keyctl print +``` + +## 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:** +```json +{ + "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 + +| 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/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 + +| 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](/docs/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: + +```toml +[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](/docs/sandbox) | Injects tool secrets into sandboxed subprocesses. Environment sanitization ensures only tool-category secrets reach workers. | +| [Configuration](/docs/config) | `secret:` prefix in config values resolves from the store at load time. Migration endpoint converts plaintext config to `secret:` references. | +| [Permissions](/docs/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. | diff --git a/docs/content/docs/(core)/agents.mdx b/docs/content/docs/(core)/agents.mdx index e89237cdf..30b73746c 100644 --- a/docs/content/docs/(core)/agents.mdx +++ b/docs/content/docs/(core)/agents.mdx @@ -232,7 +232,9 @@ Returns agents, humans, links, and groups for rendering. │ │ ├── data/ │ │ │ ├── spacebot.db # SQLite (memories, conversations, cron jobs) │ │ │ ├── lancedb/ # LanceDB (embeddings, FTS) -│ │ │ └── config.redb # redb (agent-level settings, secrets) +│ │ │ ├── config.redb # redb (agent-level settings) +│ │ │ ├── settings.redb # redb (runtime settings) +│ │ │ └── secrets.redb # redb (secret store) │ │ └── archives/ # compaction transcripts │ │ │ └── engineering/ diff --git a/docs/content/docs/(core)/architecture.mdx b/docs/content/docs/(core)/architecture.mdx index 1a8429d5f..fa0c4aa25 100644 --- a/docs/content/docs/(core)/architecture.mdx +++ b/docs/content/docs/(core)/architecture.mdx @@ -134,7 +134,9 @@ Three embedded databases, each purpose-built. No server processes, no network co ~/.spacebot/agents/{agent_id}/data/ ├── spacebot.db # SQLite — relational data ├── lancedb/ # LanceDB — vector embeddings, full-text search -└── config.redb # redb — key-value settings, encrypted secrets +├── config.redb # redb — key-value settings +├── settings.redb # redb — runtime settings +└── secrets.redb # redb — secret store (categories, encryption) ``` ### SQLite (via sqlx) @@ -171,10 +173,11 @@ The embedding model runs locally via FastEmbed -- no external API calls for embe ### redb -Embedded key-value store for configuration and secrets. +Embedded key-value stores for configuration, settings, and secrets. -- **Settings** — runtime key-value pairs (e.g., UI preferences, feature flags) -- **Encrypted secrets** — API keys and tokens encrypted with AES-256-GCM before storage +- **config.redb** — key-value pairs (UI preferences, feature flags) +- **settings.redb** — runtime settings (worker_log_mode, etc.) +- **secrets.redb** — per-agent credential storage with categories (system/tool), optional AES-256-GCM encryption at rest. See [Secret Store](/docs/secrets). Separated from SQLite so credentials can be managed and backed up independently. @@ -381,7 +384,7 @@ src/ ├── cron/ — scheduled tasks ├── api/ — 21 Axum endpoint modules ├── identity/ — identity file loading -├── secrets/ — encrypted credential storage +├── secrets/ — secret store, OS keystore, output scrubbing ├── settings/ — key-value settings ├── tasks/ — task board ├── links/ — communication graph types diff --git a/docs/content/docs/(deployment)/roadmap.mdx b/docs/content/docs/(deployment)/roadmap.mdx index 7ab637699..b1931988f 100644 --- a/docs/content/docs/(deployment)/roadmap.mdx +++ b/docs/content/docs/(deployment)/roadmap.mdx @@ -71,9 +71,9 @@ Implement `SpacebotModel.stream()` with SSE parsing. The messaging adapters alre - Wire real values into `observe()` signal extraction - Implement CortexHook observation logic (anomaly detection, consolidation triggers) -### Secrets Store +### ~~Secrets Store~~ ✓ Shipped -Encrypted credentials in redb with `DecryptedSecret` wrapper type. ChaCha20-Poly1305 AEAD with a local key file. All sensitive config values (`token`, `*_key`) encrypted at rest. +Implemented. Per-agent credential storage in `secrets.redb` with two categories (system/tool), `secret:` config resolution, auto-migration from plaintext config, and optional AES-256-GCM encryption at rest with OS keystore integration. See [Secret Store](/docs/secrets). ### Cost Tracking diff --git a/docs/content/docs/(features)/workers.mdx b/docs/content/docs/(features)/workers.mdx index b1a49b6a6..254f34df3 100644 --- a/docs/content/docs/(features)/workers.mdx +++ b/docs/content/docs/(features)/workers.mdx @@ -153,7 +153,7 @@ Worker shell and exec commands run inside an OS-level sandbox (bubblewrap on Lin The agent's data directory (databases, config files) is explicitly re-mounted read-only. This is enforced at the kernel level -- no amount of command creativity can bypass it. -Worker subprocesses also start with a **clean environment**. Workers only receive `PATH` (with `tools/bin` prepended), safe variables (`HOME`, `USER`, `LANG`, `TERM`, `TMPDIR`), and any explicitly configured `passthrough_env` entries. System secrets like LLM API keys are hidden by default and only become visible if explicitly forwarded via `passthrough_env`. Environment sanitization applies regardless of whether the sandbox is enabled or disabled. +Worker subprocesses also start with a **clean environment**. Workers only receive `PATH` (with `tools/bin` prepended), safe variables (`HOME`, `USER`, `LANG`, `TERM`, `TMPDIR`), tool-category secrets from the [secret store](/docs/secrets), and any explicitly configured `passthrough_env` entries. System secrets like LLM API keys are hidden by default unless explicitly forwarded via `passthrough_env`. Environment sanitization applies regardless of whether the sandbox is enabled or disabled. The `file` tool validates paths against the workspace boundary and rejects writes to identity/memory paths (for example `SOUL.md`, `IDENTITY.md`, `USER.md`) with an explicit error directing the LLM to the appropriate tool. The `exec` tool blocks dangerous environment variables (`LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`, etc.) that enable library injection. diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index c924dc58f..ead345b0a 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -21,7 +21,7 @@ Spacebot runs as a single binary with no server dependencies. All data lives in - **SQLite** — Relational data (conversations, memory graph, cron jobs) - **LanceDB** — Vector embeddings and full-text search -- **redb** — Key-value settings and encrypted secrets +- **redb** — Key-value settings and [secret store](/docs/secrets) ## Quick Links diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 620efe03d..482d056d1 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -1255,6 +1255,66 @@ export interface AgentMessageEvent { channel_id: string; } +// ── Secrets ────────────────────────────────────────────────────────────── + +export type SecretCategory = "system" | "tool"; +export type StoreState = "unencrypted" | "locked" | "unlocked"; + +export interface SecretStoreStatus { + state: StoreState; + encrypted: boolean; + secret_count: number; + system_count: number; + tool_count: number; + platform_managed: boolean; +} + +export interface SecretListItem { + name: string; + category: SecretCategory; + created_at: string; + updated_at: string; +} + +export interface SecretListResponse { + secrets: SecretListItem[]; +} + +export interface PutSecretResponse { + name: string; + category: SecretCategory; + reload_required: boolean; + message: string; +} + +export interface DeleteSecretResponse { + deleted: string; + warning?: string; +} + +export interface EncryptResponse { + master_key: string; + message: string; +} + +export interface UnlockResponse { + state: string; + secret_count: number; + message: string; +} + +export interface MigrationItem { + config_key: string; + secret_name: string; + category: SecretCategory; +} + +export interface MigrateResponse { + migrated: MigrationItem[]; + skipped: string[]; + message: string; +} + export const api = { status: () => fetchJson("/status"), overview: () => fetchJson("/overview"), @@ -1939,5 +1999,75 @@ export const api = { return response.json() as Promise; }, + // Secrets API + secretsStatus: () => fetchJson("/secrets/status"), + listSecrets: () => fetchJson("/secrets"), + putSecret: async (name: string, value: string, category?: SecretCategory): Promise => { + const response = await fetch(`${API_BASE}/secrets/${encodeURIComponent(name)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value, category }), + }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `API error: ${response.status}`); + } + return response.json() as Promise; + }, + deleteSecret: async (name: string): Promise => { + const response = await fetch(`${API_BASE}/secrets/${encodeURIComponent(name)}`, { + method: "DELETE", + }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `API error: ${response.status}`); + } + return response.json() as Promise; + }, + enableEncryption: async (): Promise => { + const response = await fetch(`${API_BASE}/secrets/encrypt`, { method: "POST" }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `API error: ${response.status}`); + } + return response.json() as Promise; + }, + unlockSecrets: async (masterKey: string): Promise => { + const response = await fetch(`${API_BASE}/secrets/unlock`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ master_key: masterKey }), + }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `API error: ${response.status}`); + } + return response.json() as Promise; + }, + lockSecrets: async (): Promise<{ state: string; message: string }> => { + const response = await fetch(`${API_BASE}/secrets/lock`, { method: "POST" }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `API error: ${response.status}`); + } + return response.json(); + }, + rotateKey: async (): Promise<{ master_key: string; message: string }> => { + const response = await fetch(`${API_BASE}/secrets/rotate`, { method: "POST" }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `API error: ${response.status}`); + } + return response.json(); + }, + migrateSecrets: async (): Promise => { + const response = await fetch(`${API_BASE}/secrets/migrate`, { method: "POST" }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `API error: ${response.status}`); + } + return response.json() as Promise; + }, + eventsUrl: `${API_BASE}/events`, }; diff --git a/interface/src/components/UpdateBanner.tsx b/interface/src/components/UpdateBanner.tsx deleted file mode 100644 index 86a853cc8..000000000 --- a/interface/src/components/UpdateBanner.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useState } from "react"; -import { useQuery, useMutation } from "@tanstack/react-query"; -import { api } from "@/api/client"; -import { Banner, BannerActions, Button } from "@/ui"; -import { Cancel01Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -export function UpdateBanner() { - const [dismissed, setDismissed] = useState(false); - - const { data } = useQuery({ - queryKey: ["updateCheck"], - queryFn: api.updateCheck, - staleTime: 60_000, - refetchInterval: 300_000, - }); - - const applyMutation = useMutation({ - mutationFn: api.updateApply, - onSuccess: (result) => { - if (result.status === "error") { - setApplyError(result.error ?? "Update failed"); - } - }, - }); - - const [applyError, setApplyError] = useState(null); - - // Platform-managed instances get updates via rollout, not self-service - if (!data || !data.update_available || dismissed || data.deployment === "hosted") return null; - - const isApplying = applyMutation.isPending; - const isDocker = data.deployment === "docker"; - const isNative = data.deployment === "native"; - - return ( -
- - - Version {data.latest_version} is available - (current: {data.current_version}) - - {data.release_url && ( - - Release notes - - )} - - {isDocker && data.can_apply && ( - - )} - {isDocker && !data.can_apply && ( - - {data.cannot_apply_reason ?? "Mount docker.sock for one-click updates"} - - )} - {isNative && ( - - {data.cannot_apply_reason ?? "Native/source installs update manually (rebuild + restart)"} - - )} - - - - {applyError && ( -
- {applyError} -
- )} -
- ); -} diff --git a/interface/src/components/UpdatePill.tsx b/interface/src/components/UpdatePill.tsx new file mode 100644 index 000000000..7096114b4 --- /dev/null +++ b/interface/src/components/UpdatePill.tsx @@ -0,0 +1,27 @@ +import {useQuery} from "@tanstack/react-query"; +import {Link} from "@tanstack/react-router"; +import {api} from "@/api/client"; + +export function UpdatePill() { + const {data} = useQuery({ + queryKey: ["update-check"], + queryFn: api.updateCheck, + staleTime: 60_000, + refetchInterval: 300_000, + }); + + if (!data || !data.update_available || data.deployment === "hosted") { + return null; + } + + return ( + + + Update {data.latest_version ?? "available"} + + ); +} diff --git a/interface/src/router.tsx b/interface/src/router.tsx index cc5fb0a9d..d65ce23fe 100644 --- a/interface/src/router.tsx +++ b/interface/src/router.tsx @@ -8,7 +8,6 @@ import { import {BASE_PATH} from "@/api/client"; import {ConnectionBanner} from "@/components/ConnectionBanner"; import {SetupBanner} from "@/components/SetupBanner"; -import {UpdateBanner} from "@/components/UpdateBanner"; import {Sidebar} from "@/components/Sidebar"; import {Overview} from "@/routes/Overview"; import {AgentDetail} from "@/routes/AgentDetail"; @@ -40,7 +39,6 @@ function RootLayout() { />
-
diff --git a/interface/src/routes/Overview.tsx b/interface/src/routes/Overview.tsx index 43e7200e3..68bf1b2a9 100644 --- a/interface/src/routes/Overview.tsx +++ b/interface/src/routes/Overview.tsx @@ -3,6 +3,7 @@ import {useQuery} from "@tanstack/react-query"; import {api} from "@/api/client"; import {CreateAgentDialog} from "@/components/CreateAgentDialog"; import {TopologyGraph} from "@/components/TopologyGraph"; +import {UpdatePill} from "@/components/UpdatePill"; import type {ChannelLiveState} from "@/hooks/useChannelLiveState"; import {formatUptime} from "@/lib/format"; @@ -94,12 +95,15 @@ export function Overview({liveStates, activeLinks}: OverviewProps) { )}
- +
+ + +
{/* Full-screen topology */} diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index a867da2a4..b590ec851 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { api, type GlobalSettingsResponse, type UpdateStatus } from "@/api/client"; -import { Button, Input, SettingSidebarButton, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Toggle } from "@/ui"; +import { api, type GlobalSettingsResponse, type UpdateStatus, type SecretCategory, type SecretListItem, type StoreState } from "@/api/client"; +import { Badge, Button, Input, SettingSidebarButton, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Toggle } from "@/ui"; import { useSearch, useNavigate } from "@tanstack/react-router"; import { PlatformCatalog, InstanceCard, AddInstanceCard } from "@/components/ChannelSettingCard"; import { ModelSelect } from "@/components/ModelSelect"; @@ -11,7 +11,7 @@ import { faSearch } from "@fortawesome/free-solid-svg-icons"; import { parse as parseToml } from "smol-toml"; -type SectionId = "providers" | "channels" | "api-keys" | "server" | "opencode" | "worker-logs" | "updates" | "config-file"; +type SectionId = "providers" | "channels" | "api-keys" | "secrets" | "server" | "opencode" | "worker-logs" | "updates" | "config-file"; const SECTIONS = [ { @@ -32,6 +32,12 @@ const SECTIONS = [ group: "general" as const, description: "Third-party service keys", }, + { + id: "secrets" as const, + label: "Secrets", + group: "general" as const, + description: "Encrypted secret storage", + }, { id: "server" as const, label: "Server", @@ -674,6 +680,8 @@ export function Settings() { ) : activeSection === "api-keys" ? ( + ) : activeSection === "secrets" ? ( + ) : activeSection === "server" ? ( ) : activeSection === "opencode" ? ( @@ -874,6 +882,654 @@ function ChannelsSection() { +// ── Secrets Section ────────────────────────────────────────────────────── + +function SecretsSection() { + const queryClient = useQueryClient(); + + // Store status. + const { data: storeStatus, isLoading: statusLoading } = useQuery({ + queryKey: ["secrets-status"], + queryFn: () => api.secretsStatus(), + staleTime: 5_000, + }); + + // Secret list. + const { data: secretsData, isLoading: secretsLoading } = useQuery({ + queryKey: ["secrets"], + queryFn: () => api.listSecrets(), + staleTime: 5_000, + }); + + const secrets = secretsData?.secrets ?? []; + const isLoading = statusLoading || secretsLoading; + const state: StoreState = storeStatus?.state ?? "unencrypted"; + const isLocked = state === "locked"; + const canMutate = !isLocked; + + // ── UI state ───────────────────────────────────────────────────────── + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [editingSecret, setEditingSecret] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [nameInput, setNameInput] = useState(""); + const [valueInput, setValueInput] = useState(""); + const [categoryInput, setCategoryInput] = useState("tool"); + const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null); + + // Encryption flow state. + const [encryptDialogOpen, setEncryptDialogOpen] = useState(false); + const [masterKeyDisplay, setMasterKeyDisplay] = useState(null); + const [masterKeyCopied, setMasterKeyCopied] = useState(false); + const [unlockKeyInput, setUnlockKeyInput] = useState(""); + const [rotateDialogOpen, setRotateDialogOpen] = useState(false); + + const [filterCategory, setFilterCategory] = useState<"all" | SecretCategory>("all"); + const [searchQuery, setSearchQuery] = useState(""); + + // ── Mutations ──────────────────────────────────────────────────────── + const invalidateSecrets = () => { + queryClient.invalidateQueries({ queryKey: ["secrets"] }); + queryClient.invalidateQueries({ queryKey: ["secrets-status"] }); + }; + + const putMutation = useMutation({ + mutationFn: ({ name, value, category }: { name: string; value: string; category?: SecretCategory }) => + api.putSecret(name, value, category), + onSuccess: (result) => { + invalidateSecrets(); + setAddDialogOpen(false); + setEditingSecret(null); + setNameInput(""); + setValueInput(""); + setMessage({ + text: result.reload_required + ? `${result.name} saved (${result.category}). Restart required for system secrets to take effect.` + : `${result.name} saved (${result.category}).`, + type: "success", + }); + }, + onError: (error) => { + setMessage({ text: `Failed: ${error.message}`, type: "error" }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (name: string) => api.deleteSecret(name), + onSuccess: (result) => { + invalidateSecrets(); + setDeleteTarget(null); + setMessage({ + text: result.warning + ? `Deleted ${result.deleted}. ${result.warning}` + : `Deleted ${result.deleted}.`, + type: "success", + }); + }, + onError: (error) => { + setMessage({ text: `Failed: ${error.message}`, type: "error" }); + }, + }); + + const encryptMutation = useMutation({ + mutationFn: () => api.enableEncryption(), + onSuccess: (result) => { + invalidateSecrets(); + setMasterKeyDisplay(result.master_key); + setMasterKeyCopied(false); + }, + onError: (error) => { + setMessage({ text: `Failed: ${error.message}`, type: "error" }); + setEncryptDialogOpen(false); + }, + }); + + const unlockMutation = useMutation({ + mutationFn: (key: string) => api.unlockSecrets(key), + onSuccess: () => { + invalidateSecrets(); + setUnlockKeyInput(""); + setMessage({ text: "Secret store unlocked.", type: "success" }); + }, + onError: (error) => { + setMessage({ text: `Unlock failed: ${error.message}`, type: "error" }); + }, + }); + + const lockMutation = useMutation({ + mutationFn: () => api.lockSecrets(), + onSuccess: () => { + invalidateSecrets(); + setMessage({ text: "Secret store locked.", type: "success" }); + }, + onError: (error) => { + setMessage({ text: `Failed: ${error.message}`, type: "error" }); + }, + }); + + const rotateMutation = useMutation({ + mutationFn: () => api.rotateKey(), + onSuccess: (result) => { + invalidateSecrets(); + setRotateDialogOpen(false); + setMasterKeyDisplay(result.master_key); + setMasterKeyCopied(false); + setEncryptDialogOpen(true); + }, + onError: (error) => { + setMessage({ text: `Failed: ${error.message}`, type: "error" }); + setRotateDialogOpen(false); + }, + }); + + const migrateMutation = useMutation({ + mutationFn: () => api.migrateSecrets(), + onSuccess: (result) => { + invalidateSecrets(); + setMessage({ + text: result.migrated.length > 0 + ? `Migrated ${result.migrated.length} secrets from config.toml.` + : result.message, + type: result.migrated.length > 0 ? "success" : "success", + }); + }, + onError: (error) => { + setMessage({ text: `Migration failed: ${error.message}`, type: "error" }); + }, + }); + + // ── Handlers ───────────────────────────────────────────────────────── + const handleOpenAdd = () => { + setNameInput(""); + setValueInput(""); + setCategoryInput("tool"); + setMessage(null); + setAddDialogOpen(true); + }; + + const handleOpenEdit = (secret: SecretListItem) => { + setEditingSecret(secret.name); + setNameInput(secret.name); + setValueInput(""); + setCategoryInput(secret.category); + setMessage(null); + }; + + const handleSave = () => { + const name = editingSecret ?? nameInput.trim().toUpperCase(); + if (!name || !valueInput) return; + putMutation.mutate({ name, value: valueInput, category: categoryInput }); + }; + + const handleCopyKey = async () => { + if (!masterKeyDisplay) return; + try { + await navigator.clipboard.writeText(masterKeyDisplay); + setMasterKeyCopied(true); + } catch { + // Fallback + const textarea = document.createElement("textarea"); + textarea.value = masterKeyDisplay; + textarea.setAttribute("readonly", ""); + textarea.style.position = "absolute"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + setMasterKeyCopied(true); + } + }; + + const filteredSecrets = secrets.filter((secret) => { + if (filterCategory !== "all" && secret.category !== filterCategory) return false; + if (searchQuery && !secret.name.toLowerCase().includes(searchQuery.toLowerCase())) return false; + return true; + }); + + return ( +
+ {/* Header */} +
+

Secrets

+

+ Manage credentials for LLM providers (system) and CLI tools used by workers (tool). + System secrets are never exposed to worker subprocesses. Tool secrets are injected as + environment variables. +

+
+ + {/* Status bar */} + {storeStatus && ( +
+
+
+ + {state === "unlocked" ? "Encrypted & Unlocked" + : state === "locked" ? "Encrypted & Locked" + : "Unencrypted"} + +
+
+ + {storeStatus.secret_count} secrets ({storeStatus.system_count} system, {storeStatus.tool_count} tool) + +
+ )} + + {/* Encryption banner (unencrypted stores) */} + {state === "unencrypted" && !storeStatus?.platform_managed && ( +
+
+
+

Encryption not enabled

+

+ Secrets are stored without encryption. Enable encryption for protection + against volume compromise. +

+
+ +
+
+ )} + + {/* Unlock prompt (locked stores) */} + {isLocked && ( +
+

Secrets are locked

+

+ Enter your master key to unlock encrypted secrets. You can view secret names + but cannot add, edit, or read values while locked. +

+
+ setUnlockKeyInput(e.target.value)} + placeholder="Paste master key (hex)" + className="max-w-sm font-mono text-tiny" + onKeyDown={(e) => { if (e.key === "Enter" && unlockKeyInput.trim()) unlockMutation.mutate(unlockKeyInput.trim()); }} + /> + +
+
+ )} + + {/* Feedback message */} + {message && ( +
+ {message.text} +
+ )} + + {/* Toolbar */} +
+ setSearchQuery(e.target.value)} + placeholder="Filter secrets..." + className="max-w-xs" + /> +
+ {(["all", "system", "tool"] as const).map((cat) => ( + + ))} +
+
+ {canMutate && ( + + )} +
+ + {/* Secret list */} + {isLoading ? ( +
+
+ Loading secrets... +
+ ) : filteredSecrets.length === 0 ? ( +
+

+ {secrets.length === 0 ? "No secrets yet" : "No matching secrets"} +

+

+ {secrets.length === 0 + ? "Add credentials for LLM providers or CLI tools." + : "Try a different filter."} +

+ {secrets.length === 0 && canMutate && ( +
+ + +
+ )} +
+ ) : ( +
+ {filteredSecrets.map((secret) => ( +
+
+
+ {secret.name} + + {secret.category} + +
+

+ Updated {new Date(secret.updated_at).toLocaleDateString()} +

+
+ {canMutate && ( +
+ + +
+ )} +
+ ))} +
+ )} + + {/* Bottom actions for encrypted stores */} + {storeStatus?.encrypted && !storeStatus.platform_managed && canMutate && ( +
+ + +
+ +
+ )} + + {/* Migrate button for unencrypted stores */} + {state === "unencrypted" && secrets.length > 0 && ( +
+ +
+ )} + + {/* ── Add / Edit Dialog ─────────────────────────────────────── */} + { + if (!open) { + setAddDialogOpen(false); + setEditingSecret(null); + setNameInput(""); + setValueInput(""); + } + }} + > + + + {editingSecret ? "Update Secret" : "Add Secret"} + + {editingSecret + ? `Enter a new value for ${editingSecret}. The existing value will be overwritten.` + : "Add a new credential. System secrets are internal (LLM keys, messaging tokens). Tool secrets are exposed to worker subprocesses as environment variables."} + + + + {!editingSecret && ( +
+ + setNameInput(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ""))} + placeholder="GH_TOKEN" + className="font-mono" + autoFocus + /> +

+ UPPER_SNAKE_CASE. This is also the env var name for tool secrets. +

+
+ )} + +
+ + setValueInput(e.target.value)} + placeholder={editingSecret ? "Enter new value" : "Secret value"} + autoFocus={!!editingSecret} + onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }} + /> +
+ +
+ + +

+ {categoryInput === "tool" + ? "Workers will have access to this credential via environment variable." + : "Only the Spacebot process can read this credential. Workers never see it."} +

+
+ + + + + +
+
+ + {/* ── Delete Confirmation ───────────────────────────────────── */} + { if (!open) setDeleteTarget(null); }}> + + + Delete Secret + + Are you sure you want to delete {deleteTarget}? + {" "}If this secret is referenced in config.toml, the reference will fail to resolve. + + + + + + + + + + {/* ── Enable Encryption / Master Key Display Dialog ─────────── */} + { + if (!open) { + setEncryptDialogOpen(false); + setMasterKeyDisplay(null); + setMasterKeyCopied(false); + } + }} + > + + {!masterKeyDisplay ? ( + <> + + Enable Encryption + + This will generate a master key and encrypt all secrets at rest using + AES-256-GCM. On Linux, you will need the master key to unlock secrets + after a reboot. + + + + + + + + ) : ( + <> + + Master Key Generated + + Save this key somewhere safe. On Linux, you will need it to unlock the + secret store after a reboot. This is the only time the key will be shown. + + +
+
+ + {masterKeyDisplay} + + +
+
+ If you lose this key and the OS credential store is cleared (e.g. after + a Linux reboot), you will not be able to access your encrypted secrets. +
+
+ + + + + )} +
+
+ + {/* ── Rotate Key Confirmation ───────────────────────────────── */} + { if (!open) setRotateDialogOpen(false); }}> + + + Rotate Master Key + + This will generate a new master key and re-encrypt all secrets. Your current + master key will be invalidated. You will need to save the new key. + + + + + + + + +
+ ); +} + interface GlobalSettingsSectionProps { settings: GlobalSettingsResponse | undefined; isLoading: boolean; diff --git a/prompts/en/tools/secret_set_description.md.j2 b/prompts/en/tools/secret_set_description.md.j2 new file mode 100644 index 000000000..1680e5818 --- /dev/null +++ b/prompts/en/tools/secret_set_description.md.j2 @@ -0,0 +1 @@ +Store a credential in the instance-level secret store. Use this when you obtain API keys, tokens, or passwords that should be persisted for future use. Secrets are available to all future workers as environment variables (tool category) or internally only (system category). The name should be UPPER_SNAKE_CASE. If no category is specified, it is auto-assigned based on the name. \ No newline at end of file diff --git a/prompts/en/worker.md.j2 b/prompts/en/worker.md.j2 index c2dbaa573..69ad3109f 100644 --- a/prompts/en/worker.md.j2 +++ b/prompts/en/worker.md.j2 @@ -98,6 +98,24 @@ Automate a headless Chrome browser. Use this for web scraping, testing web inter **Additional actions:** `content` (get page HTML), `evaluate` (run JavaScript, if enabled in config). +### secret_set + +Store a credential in the instance-level secret store. Use this when you create accounts, generate API keys, or obtain tokens that should persist for future use. Stored secrets become available as environment variables in all future worker sessions. + +Parameters: + +- `name` — UPPER_SNAKE_CASE identifier (e.g. `GH_TOKEN`, `STRIPE_API_KEY`) +- `value` — the credential value +- `category` — optional: `"tool"` (default, exposed as env var) or `"system"` (internal only) + +Example uses: + +- Created a GitHub bot account → store `GH_TOKEN` +- Generated a Stripe test key → store `STRIPE_TEST_KEY` +- Obtained an OAuth refresh token → store `MYAPP_REFRESH_TOKEN` + +Do not log or echo the secret value after storing it. + ## Rules 1. Do the work. Don't describe what you would do — use the tools and do it. @@ -106,3 +124,13 @@ Automate a headless Chrome browser. Use this for web scraping, testing web inter 4. When you're done with the task, you'll be asked to produce a summary. That summary is the only thing the channel sees — your tool history stays here. Focus on doing the work first, summarizing second. 5. Stay focused on the task. Don't explore tangential work unless it's necessary to complete what you were asked to do. 6. If you receive follow-up messages (interactive mode), treat them as additional instructions building on your existing context. +{% if tool_secret_names %} + +## Available Tool Secrets + +The following credentials are set as environment variables in your shell: + + {{ tool_secret_names | join(", ") }} + +Commands that use these credentials will work automatically (e.g., gh commands use GH_TOKEN). Do not echo, print, or log secret values. +{% endif %} diff --git a/src/agent/branch.rs b/src/agent/branch.rs index fb14d55a6..a16be9325 100644 --- a/src/agent/branch.rs +++ b/src/agent/branch.rs @@ -152,6 +152,14 @@ impl Branch { } }; + // Scrub tool secret values from the conclusion before sending to the + // channel. Branches can spawn workers whose output may contain secrets. + let conclusion = if let Some(store) = self.deps.runtime_config.secrets.load().as_ref() { + crate::secrets::scrub::scrub_with_store(&conclusion, store) + } else { + conclusion + }; + // Send conclusion back to the channel let _ = self.deps.event_tx.send(ProcessEvent::BranchResult { agent_id: self.deps.agent_id.clone(), diff --git a/src/agent/channel.rs b/src/agent/channel.rs index c7ca1a811..78ad67665 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -2093,6 +2093,13 @@ pub async fn spawn_worker_from_state( let sandbox_containment_active = state.deps.sandbox.containment_active(); let sandbox_read_allowlist = state.deps.sandbox.prompt_read_allowlist(); let sandbox_write_allowlist = state.deps.sandbox.prompt_write_allowlist(); + // Collect tool secret names so the worker template can list available credentials. + let secrets_guard = rc.secrets.load(); + let tool_secret_names = match (*secrets_guard).as_ref() { + Some(store) => store.tool_secret_names(), + None => Vec::new(), + }; + let worker_system_prompt = prompt_engine .render_worker_prompt( &rc.instance_dir.display().to_string(), @@ -2101,6 +2108,7 @@ pub async fn spawn_worker_from_state( sandbox_containment_active, sandbox_read_allowlist, sandbox_write_allowlist, + &tool_secret_names, ) .map_err(|e| AgentError::Other(anyhow::anyhow!("{e}")))?; let skills = rc.skills.load(); @@ -2160,11 +2168,13 @@ pub async fn spawn_worker_from_state( channel_id = %state.channel_id, task = %task, ); + let secrets_store = state.deps.runtime_config.secrets.load().as_ref().clone(); let handle = spawn_worker_task( worker_id, state.deps.event_tx.clone(), state.deps.agent_id.clone(), Some(state.channel_id.clone()), + secrets_store, worker.run().instrument(worker_span), ); @@ -2224,6 +2234,8 @@ pub async fn spawn_opencode_worker_from_state( let server_pool = rc.opencode_server_pool.clone(); + let oc_secrets_store = state.deps.runtime_config.secrets.load().as_ref().clone(); + let worker = if interactive { let (worker, input_tx) = crate::opencode::OpenCodeWorker::new_interactive( Some(state.channel_id.clone()), @@ -2239,16 +2251,23 @@ pub async fn spawn_opencode_worker_from_state( .write() .await .insert(worker_id, input_tx); - worker + match &oc_secrets_store { + Some(store) => worker.with_secrets_store(store.clone()), + None => worker, + } } else { - crate::opencode::OpenCodeWorker::new( + let worker = crate::opencode::OpenCodeWorker::new( Some(state.channel_id.clone()), state.deps.agent_id.clone(), &worker_task, directory, server_pool, state.deps.event_tx.clone(), - ) + ); + match &oc_secrets_store { + Some(store) => worker.with_secrets_store(store.clone()), + None => worker, + } }; let worker_id = worker.id; @@ -2265,6 +2284,7 @@ pub async fn spawn_opencode_worker_from_state( state.deps.event_tx.clone(), state.deps.agent_id.clone(), Some(state.channel_id.clone()), + oc_secrets_store, async move { let result = worker.run().await?; Ok::(result.result_text) @@ -2302,11 +2322,16 @@ pub async fn spawn_opencode_worker_from_state( /// Handles both success and error cases, logging failures and sending the /// appropriate event. Used by both builtin workers and OpenCode workers. /// Returns the JoinHandle so the caller can store it for cancellation. +/// +/// The result text is scrubbed through the secret store's tool secret values +/// before being sent via the event — tool secret values are replaced with +/// `[REDACTED:]` so they never propagate to channel context. fn spawn_worker_task( worker_id: WorkerId, event_tx: broadcast::Sender, agent_id: crate::AgentId, channel_id: Option, + secrets_store: Option>, future: F, ) -> tokio::task::JoinHandle<()> where @@ -2324,7 +2349,16 @@ where .inc(); let (result_text, notify, success) = match future.await { - Ok(text) => (text, true, true), + Ok(text) => { + // Scrub tool secret values from the result before it reaches + // the channel. The channel never sees raw secret values. + let scrubbed = if let Some(store) = &secrets_store { + crate::secrets::scrub::scrub_with_store(&text, store) + } else { + text + }; + (scrubbed, true, true) + } Err(error) => { tracing::error!(worker_id = %worker_id, %error, "worker failed"); (format!("Worker failed: {error}"), true, false) diff --git a/src/agent/cortex.rs b/src/agent/cortex.rs index 8daace14d..ee7965e56 100644 --- a/src/agent/cortex.rs +++ b/src/agent/cortex.rs @@ -1168,6 +1168,14 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho let sandbox_containment_active = deps.sandbox.containment_active(); let sandbox_read_allowlist = deps.sandbox.prompt_read_allowlist(); let sandbox_write_allowlist = deps.sandbox.prompt_write_allowlist(); + + // Collect tool secret names so the worker template can list available credentials. + let secrets_guard = deps.runtime_config.secrets.load(); + let tool_secret_names = match (*secrets_guard).as_ref() { + Some(store) => store.tool_secret_names(), + None => Vec::new(), + }; + let worker_system_prompt = prompt_engine .render_worker_prompt( &deps.runtime_config.instance_dir.display().to_string(), @@ -1176,6 +1184,7 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho sandbox_containment_active, sandbox_read_allowlist, sandbox_write_allowlist, + &tool_secret_names, ) .map_err(|error| anyhow::anyhow!("failed to render worker prompt: {error}"))?; diff --git a/src/api.rs b/src/api.rs index a6a8a98a1..4d83da0b9 100644 --- a/src/api.rs +++ b/src/api.rs @@ -17,6 +17,7 @@ mod memories; mod messaging; mod models; mod providers; +mod secrets; mod server; mod settings; mod skills; diff --git a/src/api/mcp.rs b/src/api/mcp.rs index 1166182ae..f3291b0b4 100644 --- a/src/api/mcp.rs +++ b/src/api/mcp.rs @@ -12,8 +12,6 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; -// ── Request / Response types ──────────────────────────────────────────── - #[derive(Deserialize)] pub(super) struct CreateMcpServerRequest { pub name: String, @@ -54,8 +52,6 @@ pub(super) struct MutationResponse { pub message: String, } -// ── Handlers ──────────────────────────────────────────────────────────── - /// GET /api/mcp/servers — list all configured MCP servers from config.toml. pub(super) async fn list_mcp_servers( State(state): State>, @@ -407,8 +403,6 @@ pub(super) async fn reconnect_mcp_server( })) } -// ── Helpers ───────────────────────────────────────────────────────────── - /// Look up live connection state for a server name across all agents. async fn get_server_state(state: &ApiState, server_name: &str) -> String { let managers = state.mcp_managers.load(); diff --git a/src/api/secrets.rs b/src/api/secrets.rs new file mode 100644 index 000000000..bc97fccd5 --- /dev/null +++ b/src/api/secrets.rs @@ -0,0 +1,760 @@ +//! Secret management API endpoints. +//! +//! Provides CRUD operations, encryption lifecycle management, and auto-migration +//! for the instance-level secret store. Secrets are global — shared across all +//! agents in the instance. + +use super::state::ApiState; +use crate::config::{ + DefaultsConfig, DiscordConfig, EmailConfig, LlmConfig, SlackConfig, TelegramConfig, + TwitchConfig, +}; +use crate::secrets::store::{ + ExportData, SecretCategory, SecretsStore, StoreState, SystemSecrets, auto_categorize, +}; + +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde::{Deserialize, Serialize}; + +use std::sync::Arc; + +/// Keystore identifier for the instance-level master key. +const KEYSTORE_INSTANCE_ID: &str = "instance"; + +fn get_secrets_store( + state: &ApiState, +) -> Result, (StatusCode, Json)> { + let guard = state.secrets_store.load(); + match (*guard).as_ref() { + Some(store) => Ok(store.clone()), + None => Err(( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({"error": "secrets store not initialized"})), + )), + } +} + +/// `GET /api/secrets/status` — Store state, counts, encryption status. +pub async fn secrets_status(State(state): State>) -> impl IntoResponse { + let store = match get_secrets_store(&state) { + Ok(s) => s, + Err(e) => return e.into_response(), + }; + + // TODO: detect platform_managed from deployment mode. + match store.status(false) { + Ok(status) => Json(status).into_response(), + Err(error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": error.to_string()})), + ) + .into_response(), + } +} + +#[derive(Serialize)] +struct SecretListItem { + name: String, + category: SecretCategory, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + +#[derive(Serialize)] +struct SecretListResponse { + secrets: Vec, +} + +/// `GET /api/secrets` — List all secrets (name + category, no values). +pub async fn list_secrets(State(state): State>) -> impl IntoResponse { + let store = match get_secrets_store(&state) { + Ok(s) => s, + Err(e) => return e.into_response(), + }; + + match store.list_metadata() { + Ok(metadata) => { + let mut secrets: Vec = metadata + .into_iter() + .map(|(name, meta)| SecretListItem { + name, + category: meta.category, + created_at: meta.created_at, + updated_at: meta.updated_at, + }) + .collect(); + secrets.sort_by(|a, b| a.name.cmp(&b.name)); + Json(SecretListResponse { secrets }).into_response() + } + Err(error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": error.to_string()})), + ) + .into_response(), + } +} + +#[derive(Debug, Deserialize)] +pub struct PutSecretBody { + pub value: String, + /// If not provided, auto-categorized based on the secret name. + pub category: Option, +} + +#[derive(Serialize)] +struct PutSecretResponse { + name: String, + category: SecretCategory, + reload_required: bool, + message: String, +} + +/// `PUT /api/secrets/:name` — Add or update a secret. +pub async fn put_secret( + State(state): State>, + Path(name): Path, + Json(body): Json, +) -> impl IntoResponse { + let store = match get_secrets_store(&state) { + Ok(s) => s, + Err(e) => return e.into_response(), + }; + + if store.state() == StoreState::Locked { + return ( + StatusCode::LOCKED, + Json(serde_json::json!({"error": "secret store is locked — unlock with master key first"})), + ) + .into_response(); + } + + let category = body.category.unwrap_or_else(|| auto_categorize(&name)); + + match store.set(&name, &body.value, category) { + Ok(()) => { + let reload_required = category == SecretCategory::System; + let message = if reload_required { + "Secret updated. Reload config or restart for the new value to take effect." + .to_string() + } else { + "Secret updated. Available to workers immediately.".to_string() + }; + Json(PutSecretResponse { + name, + category, + reload_required, + message, + }) + .into_response() + } + Err(error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": error.to_string()})), + ) + .into_response(), + } +} + +#[derive(Serialize)] +struct DeleteSecretResponse { + deleted: String, + #[serde(skip_serializing_if = "Option::is_none")] + warning: Option, +} + +/// `DELETE /api/secrets/:name` — Delete a secret. +pub async fn delete_secret( + State(state): State>, + Path(name): Path, +) -> impl IntoResponse { + let store = match get_secrets_store(&state) { + Ok(s) => s, + Err(e) => return e.into_response(), + }; + + if store.state() == StoreState::Locked { + return ( + StatusCode::LOCKED, + Json(serde_json::json!({"error": "secret store is locked — unlock with master key first"})), + ) + .into_response(); + } + + match store.delete(&name) { + Ok(()) => Json(DeleteSecretResponse { + deleted: name, + warning: None, + }) + .into_response(), + Err(error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": error.to_string()})), + ) + .into_response(), + } +} + +#[derive(Serialize)] +struct SecretInfoResponse { + name: String, + category: SecretCategory, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + +/// `GET /api/secrets/:name/info` — Secret metadata (no value). +pub async fn secret_info( + State(state): State>, + Path(name): Path, +) -> impl IntoResponse { + let store = match get_secrets_store(&state) { + Ok(s) => s, + Err(e) => return e.into_response(), + }; + + match store.get_metadata(&name) { + Ok(meta) => Json(SecretInfoResponse { + name, + category: meta.category, + created_at: meta.created_at, + updated_at: meta.updated_at, + }) + .into_response(), + Err(_) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("secret '{name}' not found")})), + ) + .into_response(), + } +} + +#[derive(Serialize)] +struct EncryptResponse { + master_key: String, + message: String, +} + +/// `POST /api/secrets/encrypt` — Enable encryption. Returns the master key (hex). +pub async fn enable_encryption(State(state): State>) -> impl IntoResponse { + let store = match get_secrets_store(&state) { + Ok(s) => s, + Err(e) => return e.into_response(), + }; + + match store.enable_encryption() { + Ok(key_bytes) => { + // Store master key in OS credential store. + let keystore = crate::secrets::keystore::platform_keystore(); + if let Err(error) = keystore.store_key(KEYSTORE_INSTANCE_ID, &key_bytes) { + tracing::warn!(%error, "failed to store master key in OS credential store — user must save the displayed key"); + } + + Json(EncryptResponse { + master_key: hex::encode(&key_bytes), + message: "Encryption enabled. Save this master key — you will need it to unlock \ + the secret manager after a reboot (Linux) or if the Keychain is reset \ + (macOS). This is the only time the key will be shown." + .to_string(), + }) + .into_response() + } + Err(error) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": error.to_string()})), + ) + .into_response(), + } +} + +#[derive(Debug, Deserialize)] +pub struct UnlockBody { + pub master_key: String, +} + +/// `POST /api/secrets/unlock` — Unlock encrypted store with master key (hex). +pub async fn unlock_secrets( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + let store = match get_secrets_store(&state) { + Ok(s) => s, + Err(e) => return e.into_response(), + }; + + let key_bytes = match hex::decode(&body.master_key) { + Ok(bytes) => bytes, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "invalid master key format (expected hex)"})), + ) + .into_response(); + } + }; + + match store.unlock(&key_bytes) { + Ok(()) => { + // Also store in OS credential store for automatic unlock on next restart. + let keystore = crate::secrets::keystore::platform_keystore(); + if let Err(error) = keystore.store_key(KEYSTORE_INSTANCE_ID, &key_bytes) { + tracing::warn!(%error, "failed to persist master key in OS credential store"); + } + + let status = store.status(false).ok(); + Json(serde_json::json!({ + "state": "unlocked", + "secret_count": status.map(|s| s.secret_count).unwrap_or(0), + "message": "Secret manager unlocked." + })) + .into_response() + } + Err(error) => { + let status = if error.to_string().contains("invalid") + || error.to_string().contains("InvalidKey") + { + StatusCode::UNAUTHORIZED + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + ( + status, + Json(serde_json::json!({"error": error.to_string()})), + ) + .into_response() + } + } +} + +/// `POST /api/secrets/lock` — Lock encrypted store (clear key from memory). +pub async fn lock_secrets(State(state): State>) -> impl IntoResponse { + let store = match get_secrets_store(&state) { + Ok(s) => s, + Err(e) => return e.into_response(), + }; + + // Clear key from OS credential store too. + let keystore = crate::secrets::keystore::platform_keystore(); + if let Err(error) = keystore.delete_key(KEYSTORE_INSTANCE_ID) { + tracing::warn!(%error, "failed to delete master key from OS credential store"); + } + + match store.lock() { + Ok(()) => Json(serde_json::json!({ + "state": "locked", + "message": "Secret manager locked. Secrets remain encrypted on disk." + })) + .into_response(), + Err(error) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": error.to_string()})), + ) + .into_response(), + } +} + +/// `POST /api/secrets/rotate` — Rotate master key. +pub async fn rotate_key(State(state): State>) -> impl IntoResponse { + let store = match get_secrets_store(&state) { + Ok(s) => s, + Err(e) => return e.into_response(), + }; + + match store.rotate_key() { + Ok(new_key) => { + // Update OS credential store with new key. + let keystore = crate::secrets::keystore::platform_keystore(); + if let Err(error) = keystore.store_key(KEYSTORE_INSTANCE_ID, &new_key) { + tracing::warn!(%error, "failed to store rotated key in OS credential store"); + } + + Json(serde_json::json!({ + "master_key": hex::encode(&new_key), + "message": "Master key rotated. Save the new key — the old key no longer works." + })) + .into_response() + } + Err(error) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": error.to_string()})), + ) + .into_response(), + } +} + +#[derive(Serialize)] +struct MigrationItem { + config_key: String, + secret_name: String, + category: SecretCategory, +} + +#[derive(Serialize)] +struct MigrateResponse { + migrated: Vec, + skipped: Vec, + message: String, +} + +/// `POST /api/secrets/migrate` — Auto-migrate literal keys from config.toml +/// to the secret store. +/// +/// Scans the resolved config for plaintext credential values (not `env:` or +/// `secret:` prefixed) and moves them to the secret store, replacing the +/// config.toml entries with `secret:NAME` references. +pub async fn migrate_secrets(State(state): State>) -> impl IntoResponse { + let store = match get_secrets_store(&state) { + Ok(s) => s, + Err(e) => return e.into_response(), + }; + + if store.state() == StoreState::Locked { + return ( + StatusCode::LOCKED, + Json(serde_json::json!({"error": "secret store is locked — unlock first"})), + ) + .into_response(); + } + + // Read the raw config.toml to find literal key values. + let config_path = state.config_path.read().await.clone(); + let config_content = match std::fs::read_to_string(&config_path) { + Ok(content) => content, + Err(error) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("failed to read config.toml: {error}")})), + ) + .into_response(); + } + }; + + let mut doc = match config_content.parse::() { + Ok(doc) => doc, + Err(error) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("failed to parse config.toml: {error}")})), + ) + .into_response(); + } + }; + + let mut migrated = Vec::new(); + let skipped = Vec::new(); + + // All secret migration is driven by SystemSecrets trait impls. + // Each config section declares its own credential fields — no hard-coded + // TOML paths needed. + // + // Non-adapter sections (LLM keys, search keys): + migrate_section_secrets::(&store, &mut doc, &mut migrated); + migrate_section_secrets::(&store, &mut doc, &mut migrated); + // Messaging adapters (default + named instances): + migrate_section_secrets::(&store, &mut doc, &mut migrated); + migrate_section_secrets::(&store, &mut doc, &mut migrated); + migrate_section_secrets::(&store, &mut doc, &mut migrated); + migrate_section_secrets::(&store, &mut doc, &mut migrated); + migrate_section_secrets::(&store, &mut doc, &mut migrated); + + // Write updated config.toml if any migrations were made. + if !migrated.is_empty() + && let Err(error) = std::fs::write(&config_path, doc.to_string()) + { + tracing::error!(%error, "failed to write updated config.toml after migration"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("migrated {count} secrets to the store but failed to update config.toml: {error}", count = migrated.len()) + })), + ) + .into_response(); + } + + let count = migrated.len(); + Json(MigrateResponse { + migrated, + skipped, + message: if count > 0 { + format!("Migrated {count} secrets. config.toml updated with secret: references.") + } else { + "No plaintext secrets found to migrate.".to_string() + }, + }) + .into_response() +} + +/// `POST /api/secrets/export` — Export all secrets as a JSON backup. +pub async fn export_secrets(State(state): State>) -> impl IntoResponse { + let store = match get_secrets_store(&state) { + Ok(s) => s, + Err(e) => return e.into_response(), + }; + + match store.export_all() { + Ok(export) => { + let mut response = serde_json::json!({ + "version": export.version, + "encrypted": export.encrypted, + "entries": export.entries, + "count": export.entries.len(), + }); + if !export.encrypted { + response["warning"] = serde_json::json!( + "Encryption is not enabled. This export contains plaintext secrets. \ + Store it securely or enable encryption first with: spacebot secrets encrypt" + ); + } + Json(response).into_response() + } + Err(error) => { + let status = if error.to_string().contains("locked") { + StatusCode::LOCKED + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + ( + status, + Json(serde_json::json!({"error": error.to_string()})), + ) + .into_response() + } + } +} + +#[derive(Debug, Deserialize)] +pub struct ImportBody { + /// The export data to import (JSON object matching ExportData format). + #[serde(flatten)] + pub data: ExportData, + /// Whether to overwrite existing secrets with the same name. + #[serde(default)] + pub overwrite: bool, +} + +/// `POST /api/secrets/import` — Import secrets from a backup. +pub async fn import_secrets( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + let store = match get_secrets_store(&state) { + Ok(s) => s, + Err(e) => return e.into_response(), + }; + + match store.import_all(&body.data, body.overwrite) { + Ok(result) => { + let message = if result.skipped.is_empty() { + format!("Imported {} secrets.", result.imported) + } else { + format!( + "Imported {} secrets. {} conflicts (existing secrets with same name): {}", + result.imported, + result.skipped.len(), + result.skipped.join(", ") + ) + }; + Json(serde_json::json!({ + "imported": result.imported, + "skipped": result.skipped, + "message": message, + })) + .into_response() + } + Err(error) => { + let status = if error.to_string().contains("locked") { + StatusCode::LOCKED + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + ( + status, + Json(serde_json::json!({"error": error.to_string()})), + ) + .into_response() + } + } +} + +/// Try to migrate a single TOML field to the secret store. +/// +/// Reads the value at `path` in the TOML document. If it's a plaintext literal +/// (not `env:` or `secret:` prefixed and not empty), stores it in the secret +/// store and replaces the TOML value with a `secret:NAME` reference. +fn try_migrate_field( + store: &SecretsStore, + doc: &mut toml_edit::DocumentMut, + path: &[&str], + secret_name: &str, + migrated: &mut Vec, +) { + let value_str = match get_toml_value(doc, path) { + Some(item) => match item.as_str() { + Some(s) => s.to_string(), + None => return, + }, + None => return, + }; + + if value_str.starts_with("env:") || value_str.starts_with("secret:") || value_str.is_empty() { + return; + } + + let category = auto_categorize(secret_name); + if let Err(error) = store.set(secret_name, &value_str, category) { + tracing::warn!(%error, secret_name, "failed to migrate secret"); + return; + } + + set_toml_value(doc, path, &format!("secret:{secret_name}")); + + migrated.push(MigrationItem { + config_key: path.join("."), + secret_name: secret_name.to_string(), + category, + }); +} + +/// Migrate all credential fields for a config section that implements [`SystemSecrets`]. +/// +/// For non-adapter sections (e.g. `LlmConfig`, `DefaultsConfig`), migrates +/// fields at `{section}.{toml_key}`. +/// +/// For messaging adapter sections (`is_messaging_adapter() == true`), additionally +/// scans `messaging.{section}.instances` for named instances and migrates each +/// instance's credential fields using the `{PREFIX}_{INSTANCE}_{SUFFIX}` naming +/// pattern (e.g. `DISCORD_ALERTS_BOT_TOKEN`). +fn migrate_section_secrets( + store: &SecretsStore, + doc: &mut toml_edit::DocumentMut, + migrated: &mut Vec, +) { + let section = T::section(); + let fields = T::secret_fields(); + + // Build the TOML path prefix for this section. + // Messaging adapters live under `messaging.{section}`, others under `{section}`. + let is_adapter = T::is_messaging_adapter(); + + // Migrate default (top-level) fields. + for field in fields { + let path: Vec<&str> = if is_adapter { + vec!["messaging", section, field.toml_key] + } else { + vec![section, field.toml_key] + }; + try_migrate_field(store, doc, &path, field.secret_name, migrated); + } + + // Named instances are only relevant for messaging adapters. + if !is_adapter { + return; + } + + // Walk the TOML array at `messaging.{section}.instances` and migrate + // each named instance's credential fields. + let instances_array = doc + .get("messaging") + .and_then(|m| m.get(section)) + .and_then(|p| p.get("instances")) + .and_then(|i| i.as_array_of_tables()) + .cloned(); + + let Some(instances) = instances_array else { + return; + }; + + for (index, instance) in instances.iter().enumerate() { + let instance_name = match instance.get("name").and_then(|n| n.as_str()) { + Some(name) if !name.is_empty() => name.to_string(), + _ => continue, + }; + + for field in fields { + let Some(secret_name) = field.instance_name(&instance_name) else { + continue; + }; + + // Read the value from the instance entry. + let value_str = match instance.get(field.toml_key).and_then(|v| v.as_str()) { + Some(s) if !s.is_empty() && !s.starts_with("env:") && !s.starts_with("secret:") => { + s.to_string() + } + _ => continue, + }; + + let category = auto_categorize(&secret_name); + if let Err(error) = store.set(&secret_name, &value_str, category) { + tracing::warn!(%error, %secret_name, "failed to migrate instance secret"); + continue; + } + + // Update the TOML value in-place within the instances array. + set_instance_toml_value( + doc, + section, + index, + field.toml_key, + &format!("secret:{secret_name}"), + ); + + migrated.push(MigrationItem { + config_key: format!("messaging.{section}.instances[{index}].{}", field.toml_key), + secret_name, + category, + }); + } + } +} + +fn get_toml_value<'a>( + doc: &'a toml_edit::DocumentMut, + path: &[&str], +) -> Option<&'a toml_edit::Item> { + let mut current: &toml_edit::Item = doc.as_item(); + for key in path { + current = current.get(key)?; + } + if current.is_none() { + return None; + } + Some(current) +} + +fn set_toml_value(doc: &mut toml_edit::DocumentMut, path: &[&str], value: &str) { + if path.is_empty() { + return; + } + let (parents, key) = path.split_at(path.len() - 1); + let mut current: &mut toml_edit::Item = doc.as_item_mut(); + for parent in parents { + if !current.is_table_like() { + return; + } + current = &mut current[parent]; + } + current[key[0]] = toml_edit::value(value); +} + +/// Set a value inside `messaging.{platform}.instances[{index}].{key}`. +fn set_instance_toml_value( + doc: &mut toml_edit::DocumentMut, + platform: &str, + index: usize, + key: &str, + value: &str, +) { + let Some(instances) = doc + .get_mut("messaging") + .and_then(|m| m.get_mut(platform)) + .and_then(|p| p.get_mut("instances")) + .and_then(|i| i.as_array_of_tables_mut()) + else { + return; + }; + if let Some(instance) = instances.get_mut(index) { + instance[key] = toml_edit::value(value); + } +} diff --git a/src/api/server.rs b/src/api/server.rs index bb7252dc4..24d2fc9e9 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -3,7 +3,7 @@ use super::state::ApiState; use super::{ agents, bindings, channels, config, cortex, cron, ingest, links, mcp, memories, messaging, - models, providers, settings, skills, system, tasks, tools, webchat, workers, + models, providers, secrets, settings, skills, system, tasks, tools, webchat, workers, }; use axum::Json; @@ -140,6 +140,21 @@ pub async fn start_http_server( .route("/agents/skills/install", post(skills::install_skill)) .route("/agents/skills/remove", delete(skills::remove_skill)) .route("/agents/tools", get(tools::list_tools)) + // Secret store management + .route("/secrets/status", get(secrets::secrets_status)) + .route("/secrets", get(secrets::list_secrets)) + .route( + "/secrets/{name}", + put(secrets::put_secret).delete(secrets::delete_secret), + ) + .route("/secrets/{name}/info", get(secrets::secret_info)) + .route("/secrets/migrate", post(secrets::migrate_secrets)) + .route("/secrets/encrypt", post(secrets::enable_encryption)) + .route("/secrets/unlock", post(secrets::unlock_secrets)) + .route("/secrets/lock", post(secrets::lock_secrets)) + .route("/secrets/rotate", post(secrets::rotate_key)) + .route("/secrets/export", post(secrets::export_secrets)) + .route("/secrets/import", post(secrets::import_secrets)) .route("/skills/registry/browse", get(skills::registry_browse)) .route("/skills/registry/search", get(skills::registry_search)) .route( diff --git a/src/api/state.rs b/src/api/state.rs index 75f00d821..20a6c2499 100644 --- a/src/api/state.rs +++ b/src/api/state.rs @@ -74,6 +74,8 @@ pub struct ApiState { pub mcp_managers: ArcSwap>>, /// Per-agent sandbox instances for process containment. pub sandboxes: ArcSwap>>, + /// Instance-level secrets store (shared across all agents). + pub secrets_store: ArcSwap>>, /// Shared reference to the Discord permissions ArcSwap (same instance used by the adapter and file watcher). pub discord_permissions: RwLock>>>, /// Shared reference to the Slack permissions ArcSwap (same instance used by the adapter and file watcher). @@ -240,6 +242,7 @@ impl ApiState { runtime_configs: ArcSwap::from_pointee(HashMap::new()), mcp_managers: ArcSwap::from_pointee(HashMap::new()), sandboxes: ArcSwap::from_pointee(HashMap::new()), + secrets_store: ArcSwap::from_pointee(None), discord_permissions: RwLock::new(None), slack_permissions: RwLock::new(None), bindings: RwLock::new(None), @@ -538,6 +541,11 @@ impl ApiState { self.sandboxes.store(Arc::new(sandboxes)); } + /// Set the instance-level secrets store. + pub fn set_secrets_store(&self, store: Arc) { + self.secrets_store.store(Arc::new(Some(store))); + } + /// Share the Discord permissions ArcSwap with the API so reads get hot-reloaded values. pub async fn set_discord_permissions(&self, permissions: Arc>) { *self.discord_permissions.write().await = Some(permissions); diff --git a/src/config.rs b/src/config.rs index 3679c75ca..c5c3b3218 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,7 @@ use crate::error::{ConfigError, Result}; use crate::llm::routing::RoutingConfig; +use crate::secrets::store::{InstancePattern, SecretField, SystemSecrets}; use anyhow::Context as _; use arc_swap::ArcSwap; use chrono_tz::Tz; @@ -325,6 +326,137 @@ impl LlmConfig { } } +impl SystemSecrets for LlmConfig { + fn section() -> &'static str { + "llm" + } + + fn secret_fields() -> &'static [SecretField] { + &[ + SecretField { + toml_key: "anthropic_key", + secret_name: "ANTHROPIC_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "anthropic_key", + secret_name: "ANTHROPIC_AUTH_TOKEN", + instance_pattern: None, + }, + SecretField { + toml_key: "openai_key", + secret_name: "OPENAI_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "openrouter_key", + secret_name: "OPENROUTER_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "kilo_key", + secret_name: "KILO_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "zhipu_key", + secret_name: "ZHIPU_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "groq_key", + secret_name: "GROQ_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "together_key", + secret_name: "TOGETHER_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "fireworks_key", + secret_name: "FIREWORKS_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "deepseek_key", + secret_name: "DEEPSEEK_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "xai_key", + secret_name: "XAI_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "mistral_key", + secret_name: "MISTRAL_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "gemini_key", + secret_name: "GEMINI_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "gemini_key", + secret_name: "GOOGLE_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "ollama_key", + secret_name: "OLLAMA_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "opencode_zen_key", + secret_name: "OPENCODE_ZEN_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "opencode_go_key", + secret_name: "OPENCODE_GO_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "nvidia_key", + secret_name: "NVIDIA_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "minimax_key", + secret_name: "MINIMAX_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "minimax_cn_key", + secret_name: "MINIMAX_CN_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "moonshot_key", + secret_name: "MOONSHOT_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "zai_coding_plan_key", + secret_name: "ZAI_CODING_PLAN_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "cerebras_key", + secret_name: "CEREBRAS_API_KEY", + instance_pattern: None, + }, + SecretField { + toml_key: "sambanova_key", + secret_name: "SAMBANOVA_API_KEY", + instance_pattern: None, + }, + ] + } +} + const ANTHROPIC_PROVIDER_BASE_URL: &str = "https://api.anthropic.com"; const OPENAI_PROVIDER_BASE_URL: &str = "https://api.openai.com"; const OPENROUTER_PROVIDER_BASE_URL: &str = "https://openrouter.ai/api"; @@ -583,6 +715,20 @@ impl std::fmt::Debug for DefaultsConfig { } } +impl SystemSecrets for DefaultsConfig { + fn section() -> &'static str { + "defaults" + } + + fn secret_fields() -> &'static [SecretField] { + &[SecretField { + toml_key: "brave_search_key", + secret_name: "BRAVE_SEARCH_API_KEY", + instance_pattern: None, + }] + } +} + /// MCP server configuration. #[derive(Debug, Clone, PartialEq, Eq)] pub struct McpServerConfig { @@ -1654,6 +1800,27 @@ impl std::fmt::Debug for DiscordConfig { } } +impl SystemSecrets for DiscordConfig { + fn section() -> &'static str { + "discord" + } + + fn is_messaging_adapter() -> bool { + true + } + + fn secret_fields() -> &'static [SecretField] { + &[SecretField { + toml_key: "token", + secret_name: "DISCORD_BOT_TOKEN", + instance_pattern: Some(InstancePattern { + platform_prefix: "DISCORD", + field_suffix: "BOT_TOKEN", + }), + }] + } +} + /// A single slash command definition for the Slack adapter. /// /// Maps a Slack slash command (e.g. `/ask`) to a target agent. @@ -1719,6 +1886,37 @@ impl std::fmt::Debug for SlackConfig { } } +impl SystemSecrets for SlackConfig { + fn section() -> &'static str { + "slack" + } + + fn is_messaging_adapter() -> bool { + true + } + + fn secret_fields() -> &'static [SecretField] { + &[ + SecretField { + toml_key: "bot_token", + secret_name: "SLACK_BOT_TOKEN", + instance_pattern: Some(InstancePattern { + platform_prefix: "SLACK", + field_suffix: "BOT_TOKEN", + }), + }, + SecretField { + toml_key: "app_token", + secret_name: "SLACK_APP_TOKEN", + instance_pattern: Some(InstancePattern { + platform_prefix: "SLACK", + field_suffix: "APP_TOKEN", + }), + }, + ] + } +} + /// Hot-reloadable Discord permission filters. /// /// Derived from bindings + discord config. Shared with the Discord adapter @@ -1950,6 +2148,27 @@ impl std::fmt::Debug for TelegramConfig { } } +impl SystemSecrets for TelegramConfig { + fn section() -> &'static str { + "telegram" + } + + fn is_messaging_adapter() -> bool { + true + } + + fn secret_fields() -> &'static [SecretField] { + &[SecretField { + toml_key: "token", + secret_name: "TELEGRAM_BOT_TOKEN", + instance_pattern: Some(InstancePattern { + platform_prefix: "TELEGRAM", + field_suffix: "BOT_TOKEN", + }), + }] + } +} + #[derive(Clone)] pub struct EmailConfig { pub enabled: bool, @@ -2048,6 +2267,53 @@ impl std::fmt::Debug for EmailConfig { } } +impl SystemSecrets for EmailConfig { + fn section() -> &'static str { + "email" + } + + fn is_messaging_adapter() -> bool { + true + } + + fn secret_fields() -> &'static [SecretField] { + &[ + SecretField { + toml_key: "imap_username", + secret_name: "EMAIL_IMAP_USERNAME", + instance_pattern: Some(InstancePattern { + platform_prefix: "EMAIL", + field_suffix: "IMAP_USERNAME", + }), + }, + SecretField { + toml_key: "imap_password", + secret_name: "EMAIL_IMAP_PASSWORD", + instance_pattern: Some(InstancePattern { + platform_prefix: "EMAIL", + field_suffix: "IMAP_PASSWORD", + }), + }, + SecretField { + toml_key: "smtp_username", + secret_name: "EMAIL_SMTP_USERNAME", + instance_pattern: Some(InstancePattern { + platform_prefix: "EMAIL", + field_suffix: "SMTP_USERNAME", + }), + }, + SecretField { + toml_key: "smtp_password", + secret_name: "EMAIL_SMTP_PASSWORD", + instance_pattern: Some(InstancePattern { + platform_prefix: "EMAIL", + field_suffix: "SMTP_PASSWORD", + }), + }, + ] + } +} + /// Hot-reloadable Telegram permission filters. /// /// Shared with the Telegram adapter via `Arc>` for hot-reloading. @@ -2187,6 +2453,53 @@ impl std::fmt::Debug for TwitchConfig { } } +impl SystemSecrets for TwitchConfig { + fn section() -> &'static str { + "twitch" + } + + fn is_messaging_adapter() -> bool { + true + } + + fn secret_fields() -> &'static [SecretField] { + &[ + SecretField { + toml_key: "oauth_token", + secret_name: "TWITCH_OAUTH_TOKEN", + instance_pattern: Some(InstancePattern { + platform_prefix: "TWITCH", + field_suffix: "OAUTH_TOKEN", + }), + }, + SecretField { + toml_key: "client_id", + secret_name: "TWITCH_CLIENT_ID", + instance_pattern: Some(InstancePattern { + platform_prefix: "TWITCH", + field_suffix: "CLIENT_ID", + }), + }, + SecretField { + toml_key: "client_secret", + secret_name: "TWITCH_CLIENT_SECRET", + instance_pattern: Some(InstancePattern { + platform_prefix: "TWITCH", + field_suffix: "CLIENT_SECRET", + }), + }, + SecretField { + toml_key: "refresh_token", + secret_name: "TWITCH_REFRESH_TOKEN", + instance_pattern: Some(InstancePattern { + platform_prefix: "TWITCH", + field_suffix: "REFRESH_TOKEN", + }), + }, + ] + } +} + /// Hot-reloadable Twitch permission filters. /// /// Shared with the Twitch adapter via `Arc>` for hot-reloading. @@ -2971,15 +3284,52 @@ struct TomlBinding { dm_allowed_users: Vec, } -/// Resolve a value that might be an "env:VAR_NAME" reference. +/// Resolve a value that might be an "env:VAR_NAME" or "secret:NAME" reference. +/// +/// Three resolution modes: +/// - `secret:NAME` — look up from the secrets store (if available). +/// - `env:VAR_NAME` — read from system environment variable. +/// - Anything else — literal value. fn resolve_env_value(value: &str) -> Option { - if let Some(var_name) = value.strip_prefix("env:") { + if let Some(alias) = value.strip_prefix("secret:") { + // Try the thread-local secrets store if set. + RESOLVE_SECRETS_STORE.with(|cell| { + cell.borrow().as_ref().and_then(|store| { + store + .get(alias) + .ok() + .map(|secret| secret.expose().to_string()) + }) + }) + } else if let Some(var_name) = value.strip_prefix("env:") { std::env::var(var_name).ok() } else { Some(value.to_string()) } } +// Thread-local reference to the secrets store for use during config resolution. +// +// Set before calling config resolution functions and cleared after. This avoids +// threading the secrets store through 60+ `resolve_env_value` call sites. +std::thread_local! { + static RESOLVE_SECRETS_STORE: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +/// Set the secrets store for config resolution on the current thread. +pub fn set_resolve_secrets_store(store: std::sync::Arc) { + RESOLVE_SECRETS_STORE.with(|cell| { + *cell.borrow_mut() = Some(store); + }); +} + +/// Clear the secrets store from the current thread. +pub fn clear_resolve_secrets_store() { + RESOLVE_SECRETS_STORE.with(|cell| { + *cell.borrow_mut() = None; + }); +} + fn normalize_timezone(value: &str) -> Option { let timezone = value.trim(); if timezone.is_empty() { @@ -5203,6 +5553,8 @@ pub struct RuntimeConfig { pub cron_scheduler: ArcSwap>>, /// Settings store for agent-specific configuration. pub settings: ArcSwap>>, + /// Secrets store for encrypted credential storage. + pub secrets: ArcSwap>>, /// Sandbox configuration for process containment. /// /// Wrapped in `Arc` so it can be shared with the `Sandbox` struct, which @@ -5259,6 +5611,7 @@ impl RuntimeConfig { cron_store: ArcSwap::from_pointee(None), cron_scheduler: ArcSwap::from_pointee(None), settings: ArcSwap::from_pointee(None), + secrets: ArcSwap::from_pointee(None), sandbox: Arc::new(ArcSwap::from_pointee(agent_config.sandbox.clone())), } } @@ -5278,6 +5631,11 @@ impl RuntimeConfig { self.settings.store(Arc::new(Some(settings))); } + /// Set the secrets store after initialization. + pub fn set_secrets(&self, secrets: Arc) { + self.secrets.store(Arc::new(Some(secrets))); + } + /// Compute the current dispatch-readiness signal. pub fn work_readiness(&self) -> WorkReadiness { let warmup_config = **self.warmup.load(); diff --git a/src/error.rs b/src/error.rs index 84baafcea..952fa34dc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -212,9 +212,12 @@ pub enum SecretsError { #[error("secret not found: {key}")] NotFound { key: String }, - #[error("invalid key format")] + #[error("invalid master key")] InvalidKey, + #[error("secret store is locked — unlock with master key first")] + StoreLocked, + #[error(transparent)] Other(#[from] anyhow::Error), } diff --git a/src/main.rs b/src/main.rs index aa8861c4d..66c4f11fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,6 +49,9 @@ enum Command { /// Manage authentication #[command(subcommand)] Auth(AuthCommand), + /// Manage secrets stored in the running instance + #[command(subcommand)] + Secrets(SecretsCommand), } #[derive(Subcommand)] @@ -115,6 +118,64 @@ enum SkillCommand { }, } +#[derive(Subcommand)] +enum SecretsCommand { + /// Show store state and secret counts + Status, + /// List all secrets (name + category) + List, + /// Add or update a secret + Set { + /// Secret name (e.g. GH_TOKEN) + name: String, + /// Secret category (system or tool) + #[arg(short, long)] + category: Option, + /// Read value from stdin instead of interactive prompt + #[arg(long)] + stdin: bool, + }, + /// Delete a secret + Delete { + /// Secret name + name: String, + }, + /// Show secret metadata and config references + Info { + /// Secret name + name: String, + }, + /// Auto-migrate plaintext keys from config.toml + Migrate, + /// Enable encryption (generate master key, encrypt all secrets) + Encrypt, + /// Unlock encrypted store + Unlock { + /// Read master key from stdin instead of interactive prompt + #[arg(long)] + stdin: bool, + }, + /// Lock encrypted store (clear key from memory) + Lock, + /// Rotate master key (encrypted mode only) + Rotate, + /// Export all secrets to a backup file + Export { + /// Output file path + #[arg(short, long)] + output: std::path::PathBuf, + }, + /// Import secrets from a backup file + Import { + /// Input file path + #[arg(short, long)] + input: std::path::PathBuf, + /// Overwrite existing secrets with same name + #[arg(long)] + overwrite: bool, + }, +} + /// Tracks an active conversation channel and its message sender. struct ActiveChannel { message_tx: mpsc::Sender, @@ -144,6 +205,7 @@ fn main() -> anyhow::Result<()> { Command::Status => cmd_status(), Command::Skill(skill_cmd) => cmd_skill(cli.config, skill_cmd), Command::Auth(auth_cmd) => cmd_auth(cli.config, auth_cmd), + Command::Secrets(secrets_cmd) => cmd_secrets(cli.config, secrets_cmd), } } @@ -170,6 +232,10 @@ fn cmd_start( None }; + // Open the instance-level secrets store so `secret:` references in config.toml + // resolve during Config::load(). The store is shared with all agents. + let bootstrapped_store = bootstrap_secrets_store(&resolved_config_path); + // Validate config loads successfully before forking let config = load_config(&resolved_config_path)?; @@ -202,7 +268,7 @@ fn cmd_start( spacebot::daemon::init_background_tracing(&paths, debug, &config.telemetry) }; - run(config, foreground, otel_provider).await + run(config, foreground, otel_provider, bootstrapped_store).await }) } @@ -390,6 +456,456 @@ fn cmd_auth(config_path: Option, auth_cmd: AuthCommand) -> a }) } +fn cmd_secrets( + config_path: Option, + secrets_cmd: SecretsCommand, +) -> anyhow::Result<()> { + // Bootstrap the secrets store so `secret:` references in config resolve. + bootstrap_secrets_store(&config_path); + + let config = load_config(&config_path)?; + let api_base = format!("http://{}:{}/api", config.api.bind, config.api.port); + let auth_token = config.api.auth_token.clone(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("failed to build tokio runtime")?; + + runtime.block_on(async { + let client = reqwest::Client::new(); + + match secrets_cmd { + SecretsCommand::Status => { + let response = + secrets_api_get(&client, &api_base, &auth_token, "secrets/status").await?; + let body: serde_json::Value = response.json().await?; + eprintln!( + "State: {}", + body["state"].as_str().unwrap_or("unknown") + ); + eprintln!( + "Encrypted: {}", + body["encrypted"].as_bool().unwrap_or(false) + ); + eprintln!( + "Total secrets: {}", + body["secret_count"].as_u64().unwrap_or(0) + ); + eprintln!( + " System: {}", + body["system_count"].as_u64().unwrap_or(0) + ); + eprintln!( + " Tool: {}", + body["tool_count"].as_u64().unwrap_or(0) + ); + if body["platform_managed"].as_bool().unwrap_or(false) { + eprintln!(" Managed by: platform"); + } + Ok(()) + } + SecretsCommand::List => { + let response = secrets_api_get(&client, &api_base, &auth_token, "secrets").await?; + let body: serde_json::Value = response.json().await?; + let secrets = body["secrets"].as_array(); + match secrets { + Some(list) if !list.is_empty() => { + eprintln!("{:<30} {:<10} UPDATED", "NAME", "CATEGORY"); + for secret in list { + let name = secret["name"].as_str().unwrap_or(""); + let category = secret["category"].as_str().unwrap_or(""); + let updated = secret["updated_at"].as_str().unwrap_or(""); + // Truncate ISO timestamp to date + time. + let short_date = &updated[..updated.len().min(16)]; + eprintln!("{:<30} {:<10} {}", name, category, short_date); + } + } + _ => { + eprintln!("No secrets stored."); + } + } + Ok(()) + } + SecretsCommand::Set { + name, + category, + stdin, + } => { + let value = if stdin { + let mut buf = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?; + buf.trim_end().to_string() + } else { + dialoguer::Password::new() + .with_prompt("Enter value") + .interact() + .context("failed to read secret value")? + }; + + if value.is_empty() { + anyhow::bail!("secret value cannot be empty"); + } + + let mut body = serde_json::json!({ "value": value }); + if let Some(cat) = &category { + body["category"] = serde_json::json!(cat); + } + + let response = secrets_api_put( + &client, + &api_base, + &auth_token, + &format!("secrets/{name}"), + &body, + ) + .await?; + + if response.status().is_success() { + let result: serde_json::Value = response.json().await?; + eprintln!( + "Secret {} saved ({}).", + result["name"].as_str().unwrap_or(&name), + result["category"].as_str().unwrap_or("unknown") + ); + if result["reload_required"].as_bool().unwrap_or(false) { + eprintln!( + "Note: Reload config or restart for the new value to take effect." + ); + } + } else { + let error: serde_json::Value = response.json().await?; + anyhow::bail!("{}", error["error"].as_str().unwrap_or("unknown error")); + } + Ok(()) + } + SecretsCommand::Delete { name } => { + let response = + secrets_api_delete(&client, &api_base, &auth_token, &format!("secrets/{name}")) + .await?; + + if response.status().is_success() { + let result: serde_json::Value = response.json().await?; + eprintln!("Deleted {}.", result["deleted"].as_str().unwrap_or(&name)); + if let Some(warning) = result["warning"].as_str() { + eprintln!("Warning: {warning}"); + } + } else { + let error: serde_json::Value = response.json().await?; + anyhow::bail!("{}", error["error"].as_str().unwrap_or("unknown error")); + } + Ok(()) + } + SecretsCommand::Info { name } => { + let response = secrets_api_get( + &client, + &api_base, + &auth_token, + &format!("secrets/{name}/info"), + ) + .await?; + + if response.status().is_success() { + let info: serde_json::Value = response.json().await?; + eprintln!("Name: {}", info["name"].as_str().unwrap_or("")); + eprintln!("Category: {}", info["category"].as_str().unwrap_or("")); + eprintln!("Created: {}", info["created_at"].as_str().unwrap_or("")); + eprintln!("Updated: {}", info["updated_at"].as_str().unwrap_or("")); + } else { + let error: serde_json::Value = response.json().await?; + anyhow::bail!("{}", error["error"].as_str().unwrap_or("secret not found")); + } + Ok(()) + } + SecretsCommand::Migrate => { + let response = secrets_api_post( + &client, + &api_base, + &auth_token, + "secrets/migrate", + &serde_json::json!({}), + ) + .await?; + + if response.status().is_success() { + let result: serde_json::Value = response.json().await?; + eprintln!( + "{}", + result["message"].as_str().unwrap_or("Migration complete.") + ); + if let Some(migrated) = result["migrated"].as_array() { + for item in migrated { + eprintln!( + " {} -> {} ({})", + item["config_key"].as_str().unwrap_or(""), + item["secret_name"].as_str().unwrap_or(""), + item["category"].as_str().unwrap_or(""), + ); + } + } + } else { + let error: serde_json::Value = response.json().await?; + anyhow::bail!("{}", error["error"].as_str().unwrap_or("migration failed")); + } + Ok(()) + } + SecretsCommand::Encrypt => { + let response = secrets_api_post( + &client, + &api_base, + &auth_token, + "secrets/encrypt", + &serde_json::json!({}), + ) + .await?; + + if response.status().is_success() { + let result: serde_json::Value = response.json().await?; + eprintln!(); + eprintln!("Encryption enabled."); + eprintln!(); + eprintln!( + "Master key: {}", + result["master_key"].as_str().unwrap_or("") + ); + eprintln!(); + eprintln!("IMPORTANT: Save this master key. You will need it to unlock"); + eprintln!("the secret store after a reboot (Linux) or if the Keychain"); + eprintln!("is reset (macOS). This is the only time the key will be shown."); + } else { + let error: serde_json::Value = response.json().await?; + anyhow::bail!("{}", error["error"].as_str().unwrap_or("encryption failed")); + } + Ok(()) + } + SecretsCommand::Unlock { stdin } => { + let master_key = if stdin { + let mut buf = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?; + buf.trim().to_string() + } else { + dialoguer::Password::new() + .with_prompt("Enter master key") + .interact() + .context("failed to read master key")? + }; + + let response = secrets_api_post( + &client, + &api_base, + &auth_token, + "secrets/unlock", + &serde_json::json!({ "master_key": master_key }), + ) + .await?; + + if response.status().is_success() { + let result: serde_json::Value = response.json().await?; + eprintln!("{}", result["message"].as_str().unwrap_or("Unlocked.")); + } else { + let error: serde_json::Value = response.json().await?; + anyhow::bail!("{}", error["error"].as_str().unwrap_or("unlock failed")); + } + Ok(()) + } + SecretsCommand::Lock => { + let response = secrets_api_post( + &client, + &api_base, + &auth_token, + "secrets/lock", + &serde_json::json!({}), + ) + .await?; + + if response.status().is_success() { + let result: serde_json::Value = response.json().await?; + eprintln!("{}", result["message"].as_str().unwrap_or("Locked.")); + } else { + let error: serde_json::Value = response.json().await?; + anyhow::bail!("{}", error["error"].as_str().unwrap_or("lock failed")); + } + Ok(()) + } + SecretsCommand::Rotate => { + eprintln!("WARNING: This will invalidate your current master key."); + eprintln!("You will need to save the new key for future unlocks."); + eprint!("Continue? [y/N]: "); + let mut confirm = String::new(); + std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut confirm)?; + if !confirm.trim().eq_ignore_ascii_case("y") { + eprintln!("Cancelled."); + return Ok(()); + } + + let response = secrets_api_post( + &client, + &api_base, + &auth_token, + "secrets/rotate", + &serde_json::json!({}), + ) + .await?; + + if response.status().is_success() { + let result: serde_json::Value = response.json().await?; + eprintln!(); + eprintln!( + "New master key: {}", + result["master_key"].as_str().unwrap_or("") + ); + eprintln!(); + eprintln!("IMPORTANT: Save this new key. Your old key no longer works."); + } else { + let error: serde_json::Value = response.json().await?; + anyhow::bail!("{}", error["error"].as_str().unwrap_or("rotation failed")); + } + Ok(()) + } + SecretsCommand::Export { output } => { + let response = secrets_api_post( + &client, + &api_base, + &auth_token, + "secrets/export", + &serde_json::json!({}), + ) + .await?; + + if response.status().is_success() { + let body: serde_json::Value = response.json().await?; + let count = body["count"].as_u64().unwrap_or(0); + let content = serde_json::to_string_pretty(&body) + .context("failed to serialize export data")?; + std::fs::write(&output, content) + .with_context(|| format!("failed to write {}", output.display()))?; + eprintln!("Exported {count} secrets to {}", output.display()); + + if let Some(warning) = body["warning"].as_str() { + eprintln!(); + eprintln!("WARNING: {warning}"); + } + } else { + let error: serde_json::Value = response.json().await?; + anyhow::bail!("{}", error["error"].as_str().unwrap_or("export failed")); + } + Ok(()) + } + SecretsCommand::Import { input, overwrite } => { + let content = std::fs::read_to_string(&input) + .with_context(|| format!("failed to read {}", input.display()))?; + let mut import_data: serde_json::Value = serde_json::from_str(&content) + .context("failed to parse backup file as JSON")?; + + import_data["overwrite"] = serde_json::json!(overwrite); + + let response = secrets_api_post( + &client, + &api_base, + &auth_token, + "secrets/import", + &import_data, + ) + .await?; + + if response.status().is_success() { + let result: serde_json::Value = response.json().await?; + eprintln!( + "{}", + result["message"].as_str().unwrap_or("Import complete.") + ); + if let Some(skipped) = result["skipped"].as_array() + && !skipped.is_empty() + { + for name in skipped { + eprintln!( + " {} -- kept existing (use --overwrite to replace)", + name.as_str().unwrap_or("") + ); + } + } + } else { + let error: serde_json::Value = response.json().await?; + anyhow::bail!("{}", error["error"].as_str().unwrap_or("import failed")); + } + Ok(()) + } + } + }) +} + +/// Build an authenticated HTTP request to the control API. +fn secrets_api_request( + client: &reqwest::Client, + method: reqwest::Method, + api_base: &str, + auth_token: &Option, + path: &str, +) -> reqwest::RequestBuilder { + let url = format!("{api_base}/{path}"); + let mut request = client.request(method, &url); + if let Some(token) = auth_token { + request = request.bearer_auth(token); + } + request +} + +async fn secrets_api_get( + client: &reqwest::Client, + api_base: &str, + auth_token: &Option, + path: &str, +) -> anyhow::Result { + let response = secrets_api_request(client, reqwest::Method::GET, api_base, auth_token, path) + .send() + .await + .context("failed to connect to spacebot API — is the daemon running?")?; + Ok(response) +} + +async fn secrets_api_post( + client: &reqwest::Client, + api_base: &str, + auth_token: &Option, + path: &str, + body: &serde_json::Value, +) -> anyhow::Result { + let response = secrets_api_request(client, reqwest::Method::POST, api_base, auth_token, path) + .json(body) + .send() + .await + .context("failed to connect to spacebot API — is the daemon running?")?; + Ok(response) +} + +async fn secrets_api_put( + client: &reqwest::Client, + api_base: &str, + auth_token: &Option, + path: &str, + body: &serde_json::Value, +) -> anyhow::Result { + let response = secrets_api_request(client, reqwest::Method::PUT, api_base, auth_token, path) + .json(body) + .send() + .await + .context("failed to connect to spacebot API — is the daemon running?")?; + Ok(response) +} + +async fn secrets_api_delete( + client: &reqwest::Client, + api_base: &str, + auth_token: &Option, + path: &str, +) -> anyhow::Result { + let response = secrets_api_request(client, reqwest::Method::DELETE, api_base, auth_token, path) + .send() + .await + .context("failed to connect to spacebot API — is the daemon running?")?; + Ok(response) +} + fn cmd_skill( config_path: Option, skill_cmd: SkillCommand, @@ -587,6 +1103,177 @@ fn load_config( } } +/// Pre-open secrets stores before config loading so `secret:` references in +/// config.toml can resolve. +/// +/// Config resolution happens in `Config::load()`, which calls `resolve_env_value()` +/// for every credential field. That function checks the thread-local +/// `RESOLVE_SECRETS_STORE` for `secret:` prefixed values. Without this bootstrap, +/// all `secret:` references resolve to `None` and the config fails validation +/// (e.g., messaging adapters see empty tokens and error out). +/// +/// Returns the pre-opened stores keyed by agent ID. These are reused later in +/// `initialize_agents()` to avoid double-opening the redb files. +/// Keystore identifier for the instance-level master key. +const KEYSTORE_INSTANCE_ID: &str = "instance"; + +/// Open the instance-level secrets store at `/data/secrets.redb` +/// before config loading so that `secret:` references in config.toml resolve. +/// +/// If no instance-level store exists but per-agent stores do (from the previous +/// per-agent model), secrets are migrated from the first non-empty agent store. +fn bootstrap_secrets_store( + config_path: &Option, +) -> Option> { + let instance_dir = if let Some(path) = config_path { + path.parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")) + } else { + spacebot::config::Config::default_instance_dir() + }; + + let data_dir = instance_dir.join("data"); + if let Err(error) = std::fs::create_dir_all(&data_dir) { + eprintln!("warning: failed to create instance data directory: {error}"); + return None; + } + + let secrets_path = data_dir.join("secrets.redb"); + let is_new_store = !secrets_path.exists(); + + let store = match spacebot::secrets::store::SecretsStore::new(&secrets_path) { + Ok(store) => Arc::new(store), + Err(error) => { + eprintln!("warning: failed to open secrets store: {error}"); + return None; + } + }; + + // Migrate from legacy per-agent stores if the instance store is brand new. + if is_new_store { + migrate_legacy_agent_stores(&instance_dir, &store); + } + + // Try to auto-unlock if encrypted. + if store.is_encrypted() { + let keystore = spacebot::secrets::keystore::platform_keystore(); + + // Hosted: check tmpfs-injected key. + let tmpfs_key_path = std::path::Path::new("/run/spacebot/master_key"); + let master_key = if tmpfs_key_path.exists() { + std::fs::read(tmpfs_key_path).ok().inspect(|key| { + let _ = std::fs::remove_file(tmpfs_key_path); + let _ = keystore.store_key(KEYSTORE_INSTANCE_ID, key); + }) + } else { + // Try instance-level key first, then fall back to legacy agent keys. + keystore + .load_key(KEYSTORE_INSTANCE_ID) + .ok() + .flatten() + .or_else(|| load_legacy_keystore_key(&instance_dir)) + }; + + if let Some(key) = master_key { + let _ = store.unlock(&key); + } + } + + // Set the store into the thread-local for config resolution. + spacebot::config::set_resolve_secrets_store(store.clone()); + + Some(store) +} + +/// Migrate secrets from legacy per-agent redb stores into the new instance-level +/// store. Only runs once when the instance-level store is first created. +fn migrate_legacy_agent_stores( + instance_dir: &std::path::Path, + target_store: &spacebot::secrets::store::SecretsStore, +) { + let agents_dir = instance_dir.join("agents"); + let entries = match std::fs::read_dir(&agents_dir) { + Ok(entries) => entries, + Err(_) => return, + }; + + let mut total_migrated = 0usize; + + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|ft| ft.is_dir()) { + continue; + } + let secrets_path = entry.path().join("data").join("secrets.redb"); + if !secrets_path.exists() { + continue; + } + + // Open the legacy agent store (read-only access). + let legacy_store = match spacebot::secrets::store::SecretsStore::new(&secrets_path) { + Ok(store) => store, + Err(_) => continue, + }; + + // If the legacy store is encrypted, try to unlock it with OS keystore. + if legacy_store.is_encrypted() { + let agent_id = entry.file_name().to_string_lossy().to_string(); + let keystore = spacebot::secrets::keystore::platform_keystore(); + if let Some(key) = keystore.load_key(&agent_id).ok().flatten() { + let _ = legacy_store.unlock(&key); + } else { + continue; // Can't read encrypted store without key. + } + } + + // Export all secrets from the legacy store. + let export = match legacy_store.export_all() { + Ok(export) => export, + Err(_) => continue, + }; + + // Import into the target store (don't overwrite — first agent wins for + // duplicates, which is fine since all agents had the same secrets). + match target_store.import_all(&export, false) { + Ok(result) => { + total_migrated += result.imported; + } + Err(error) => { + eprintln!( + "warning: failed to migrate secrets from {}: {error}", + secrets_path.display() + ); + } + } + } + + if total_migrated > 0 { + eprintln!( + "info: migrated {total_migrated} secrets from legacy per-agent stores to instance store" + ); + } +} + +/// Try to load a master key from legacy per-agent keystore entries. +fn load_legacy_keystore_key(instance_dir: &std::path::Path) -> Option> { + let agents_dir = instance_dir.join("agents"); + let entries = std::fs::read_dir(&agents_dir).ok()?; + let keystore = spacebot::secrets::keystore::platform_keystore(); + + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|ft| ft.is_dir()) { + continue; + } + let agent_id = entry.file_name().to_string_lossy().to_string(); + if let Ok(Some(key)) = keystore.load_key(&agent_id) { + // Migrate the key to the instance-level keystore entry. + let _ = keystore.store_key(KEYSTORE_INSTANCE_ID, &key); + return Some(key); + } + } + None +} + fn has_provider_credentials( llm_config: &spacebot::config::LlmConfig, instance_dir: &std::path::Path, @@ -600,6 +1287,7 @@ async fn run( config: spacebot::config::Config, foreground: bool, otel_provider: Option, + bootstrapped_store: Option>, ) -> anyhow::Result<()> { let paths = spacebot::daemon::DaemonPaths::new(&config.instance_dir); @@ -771,6 +1459,7 @@ async fn run( &mut telegram_permissions, &mut twitch_permissions, agent_links.clone(), + &bootstrapped_store, ) .await?; agents_initialized = true; @@ -1113,6 +1802,7 @@ async fn run( &mut new_telegram_permissions, &mut new_twitch_permissions, agent_links.clone(), + &bootstrapped_store, ).await { Ok(()) => { agents_initialized = true; @@ -1246,6 +1936,7 @@ async fn initialize_agents( telegram_permissions: &mut Option>>, twitch_permissions: &mut Option>>, agent_links: Arc>>, + bootstrapped_store: &Option>, ) -> anyhow::Result<()> { let resolved_agents = config.resolve_agents(); @@ -1376,6 +2067,12 @@ async fn initialize_agents( tracing::warn!(%error, agent = %agent_config.id, "failed to set worker_log_mode from config"); } + // Share the instance-level secrets store with this agent. + if let Some(secrets_store) = bootstrapped_store { + runtime_config.set_secrets(secrets_store.clone()); + spacebot::config::set_resolve_secrets_store(secrets_store.clone()); + } + watcher_agents.push(( agent_config.id.clone(), agent_config.workspace.clone(), @@ -1393,6 +2090,11 @@ async fn initialize_agents( .await, ); + // Wire the instance-level secrets store into the sandbox for tool secret injection. + if let Some(secrets_store) = &bootstrapped_store { + sandbox.set_secrets_store(secrets_store.clone()); + } + let deps = spacebot::AgentDeps { agent_id: agent_id.clone(), memory_search, @@ -1461,6 +2163,10 @@ async fn initialize_agents( api_state.set_runtime_configs(runtime_configs); api_state.set_agent_workspaces(agent_workspaces); api_state.set_sandboxes(sandboxes); + // Wire the instance-level secrets store into the API state. + if let Some(store) = &bootstrapped_store { + api_state.set_secrets_store(store.clone()); + } api_state.set_instance_dir(config.instance_dir.clone()); } diff --git a/src/opencode/worker.rs b/src/opencode/worker.rs index 6d68aa1c0..e6c4f7c17 100644 --- a/src/opencode/worker.rs +++ b/src/opencode/worker.rs @@ -6,6 +6,7 @@ use crate::opencode::server::OpenCodeServerPool; use crate::opencode::types::*; +use crate::secrets::store::SecretsStore; use crate::{AgentId, ChannelId, ProcessEvent, WorkerId}; use anyhow::{Context as _, bail}; @@ -30,6 +31,8 @@ pub struct OpenCodeWorker { pub system_prompt: Option, /// Model override (provider/model format like "anthropic/claude-sonnet-4"). pub model: Option, + /// Secrets store for exact-match scrubbing of tool secret values in SSE output. + pub secrets_store: Option>, } /// Result of an OpenCode worker run. @@ -59,6 +62,7 @@ impl OpenCodeWorker { input_rx: None, system_prompt: None, model: None, + secrets_store: None, } } @@ -89,6 +93,21 @@ impl OpenCodeWorker { self } + /// Set the secrets store for exact-match scrubbing of tool secret values. + pub fn with_secrets_store(mut self, store: Arc) -> Self { + self.secrets_store = Some(store); + self + } + + /// Scrub tool secret values from text, replacing each with `[REDACTED:]`. + /// Returns the scrubbed text. If no secrets store is set, returns the input unchanged. + fn scrub_text(&self, text: &str) -> String { + match &self.secrets_store { + Some(store) => crate::secrets::scrub::scrub_with_store(text, store), + None => text.to_string(), + } + } + /// Run the worker: spawn/reuse an OpenCode server, create a session, /// send the task, monitor via SSE, and return the result. pub async fn run(mut self) -> anyhow::Result { @@ -318,10 +337,14 @@ impl OpenCodeWorker { } *has_assistant_message = true; - // Leak detection: scan text content for known secret patterns. + // Exact-match scrubbing: replace known tool secret values + // before leak detection so they don't trigger false positives. + let scrubbed = self.scrub_text(text); + + // Leak detection: scan scrubbed text for known secret patterns. // OpenCode output is not scanned by SpacebotHook, so this is // the only leak protection layer for OpenCode workers. - if crate::secrets::scrub::scan_for_leaks(text).is_some() { + if crate::secrets::scrub::scan_for_leaks(&scrubbed).is_some() { tracing::error!( worker_id = %self.id, "LEAK DETECTED in OpenCode worker output — terminating" @@ -329,7 +352,7 @@ impl OpenCodeWorker { return EventAction::Error("leak detected in output".to_string()); } - *last_text = text.clone(); + *last_text = scrubbed; } Part::Tool { tool, @@ -353,19 +376,21 @@ impl OpenCodeWorker { self.send_status(&format!("running: {label}")); } ToolState::Completed { output, .. } => { - // Scan tool output for leaks - if let Some(tool_output) = output - && crate::secrets::scrub::scan_for_leaks(tool_output) + // Scrub + scan tool output for leaks + if let Some(tool_output) = output { + let scrubbed = self.scrub_text(tool_output); + if crate::secrets::scrub::scan_for_leaks(&scrubbed) .is_some() - { - tracing::error!( - worker_id = %self.id, - tool = %tool_name, - "LEAK DETECTED in OpenCode tool output — terminating" - ); - return EventAction::Error( - "leak detected in tool output".to_string(), - ); + { + tracing::error!( + worker_id = %self.id, + tool = %tool_name, + "LEAK DETECTED in OpenCode tool output — terminating" + ); + return EventAction::Error( + "leak detected in tool output".to_string(), + ); + } } if current_tool.as_deref() == Some(tool_name.as_str()) { diff --git a/src/prompts/engine.rs b/src/prompts/engine.rs index 25edd06a6..6fa73a3f3 100644 --- a/src/prompts/engine.rs +++ b/src/prompts/engine.rs @@ -1,6 +1,6 @@ use crate::error::Result; use anyhow::Context; -use minijinja::{Environment, Value, context}; +use minijinja::{context, Environment, Value}; use serde::Serialize; use std::collections::HashMap; use std::sync::Arc; @@ -241,7 +241,9 @@ impl PromptEngine { ) } - /// Render the worker system prompt with filesystem context. + /// Render the worker system prompt with filesystem context and optional tool + /// secret names. + #[allow(clippy::too_many_arguments)] pub fn render_worker_prompt( &self, instance_dir: &str, @@ -250,6 +252,7 @@ impl PromptEngine { sandbox_containment_active: bool, sandbox_read_allowlist: Vec, sandbox_write_allowlist: Vec, + tool_secret_names: &[String], ) -> Result { self.render( "worker", @@ -260,6 +263,7 @@ impl PromptEngine { sandbox_containment_active => sandbox_containment_active, sandbox_read_allowlist => sandbox_read_allowlist, sandbox_write_allowlist => sandbox_write_allowlist, + tool_secret_names => tool_secret_names, }, ) } @@ -477,7 +481,11 @@ impl PromptEngine { match self.render_static(template_name) { Ok(value) => { let value = value.trim().to_string(); - if value.is_empty() { None } else { Some(value) } + if value.is_empty() { + None + } else { + Some(value) + } } Err(error) => { tracing::error!(template_name, %error, "failed to render adapter prompt template"); diff --git a/src/prompts/text.rs b/src/prompts/text.rs index 2e92f05f3..177d6132a 100644 --- a/src/prompts/text.rs +++ b/src/prompts/text.rs @@ -174,6 +174,9 @@ fn lookup(lang: &str, key: &str) -> &'static str { ("en", "tools/send_message_to_another_channel") => { include_str!("../../prompts/en/tools/send_message_description.md.j2") } + ("en", "tools/secret_set") => { + include_str!("../../prompts/en/tools/secret_set_description.md.j2") + } ("en", "tools/send_agent_message") => { include_str!("../../prompts/en/tools/send_agent_message_description.md.j2") } diff --git a/src/sandbox.rs b/src/sandbox.rs index c51b4f606..207635b29 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -7,6 +7,7 @@ use arc_swap::ArcSwap; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::process::Command; @@ -118,6 +119,11 @@ pub struct Sandbox { data_dir: PathBuf, tools_bin: PathBuf, backend: SandboxBackend, + /// Reference to the secrets store for injecting tool secrets into worker + /// subprocesses. When set, `wrap()` reads tool secrets from the store and + /// injects them as env vars via `--setenv` (bubblewrap) or `Command::env()` + /// (passthrough/sandbox-exec). + secrets_store: ArcSwap>>, } impl std::fmt::Debug for Sandbox { @@ -190,6 +196,24 @@ impl Sandbox { data_dir, tools_bin, backend, + secrets_store: ArcSwap::from_pointee(None), + } + } + + /// Set the secrets store for tool secret injection into worker subprocesses. + /// + /// Called after the secrets store is initialized (may happen after sandbox + /// construction during agent startup). + pub fn set_secrets_store(&self, store: Arc) { + self.secrets_store.store(Arc::new(Some(store))); + } + + /// Read tool secrets from the store for injection into subprocess environment. + fn tool_secrets(&self) -> HashMap { + let guard = self.secrets_store.load(); + match guard.as_ref() { + Some(store) => store.tool_env_vars(), + None => HashMap::new(), } } @@ -317,8 +341,11 @@ impl Sandbox { None => self.tools_bin.to_string_lossy().into_owned(), }; + // Read tool secrets once for injection into the subprocess. + let tool_secrets = self.tool_secrets(); + if config.mode == SandboxMode::Disabled { - return self.wrap_passthrough(program, args, working_dir, &path_env, &config); + return self.wrap_passthrough(program, args, working_dir, &path_env, &config, &tool_secrets); } match self.backend { @@ -329,17 +356,24 @@ impl Sandbox { proc_supported, &path_env, &config, + &tool_secrets, + ), + SandboxBackend::SandboxExec => self.wrap_sandbox_exec( + program, + args, + working_dir, + &path_env, + &config, + &tool_secrets, ), - SandboxBackend::SandboxExec => { - self.wrap_sandbox_exec(program, args, working_dir, &path_env, &config) - } SandboxBackend::None => { - self.wrap_passthrough(program, args, working_dir, &path_env, &config) + self.wrap_passthrough(program, args, working_dir, &path_env, &config, &tool_secrets) } } } /// Linux: wrap with bubblewrap mount namespace. + #[allow(clippy::too_many_arguments)] fn wrap_bubblewrap( &self, program: &str, @@ -348,6 +382,7 @@ impl Sandbox { proc_supported: bool, path_env: &str, config: &SandboxConfig, + tool_secrets: &HashMap, ) -> Command { let mut cmd = Command::new("bwrap"); @@ -429,6 +464,13 @@ impl Sandbox { } } + // 13. Re-inject tool secrets from the secret store. + // Only tool-category secrets are injected; system secrets (LLM API keys, + // messaging tokens) never enter subprocess environments. + for (name, value) in tool_secrets { + cmd.arg("--setenv").arg(name).arg(value); + } + // 14. Re-inject passthrough env vars (user-configured forwarding), // skipping any that would override hardened defaults. for var_name in &config.passthrough_env { @@ -441,7 +483,20 @@ impl Sandbox { } } - // 15. The actual command + // 15. Worker keyring isolation (Linux) — give the child a fresh empty + // session keyring so it cannot access the parent's keyring (which holds + // the master key for secret store encryption). + #[cfg(target_os = "linux")] + { + // pre_exec runs between fork and exec. If it fails, spawn() fails + // and the worker is not started (correct — a worker that inherits + // the parent's session keyring could access the master key). + unsafe { + cmd.pre_exec(|| crate::secrets::keystore::pre_exec_new_session_keyring()); + } + } + + // 16. The actual command cmd.arg("--").arg(program); for arg in args { cmd.arg(arg); @@ -458,6 +513,7 @@ impl Sandbox { working_dir: &Path, path_env: &str, config: &SandboxConfig, + tool_secrets: &HashMap, ) -> Command { let profile = self.generate_sbpl_profile(config); @@ -480,6 +536,10 @@ impl Sandbox { cmd.env(var_name, value); } } + // Inject tool secrets from the secret store. + for (name, value) in tool_secrets { + cmd.env(name, value); + } for var_name in &config.passthrough_env { if is_reserved_env_var(var_name) { tracing::debug!(%var_name, "skipping reserved passthrough_env variable"); @@ -504,6 +564,7 @@ impl Sandbox { working_dir: &Path, path_env: &str, config: &SandboxConfig, + tool_secrets: &HashMap, ) -> Command { let mut cmd = Command::new(program); for arg in args { @@ -522,6 +583,10 @@ impl Sandbox { cmd.env(var_name, value); } } + // Inject tool secrets from the secret store. + for (name, value) in tool_secrets { + cmd.env(name, value); + } for var_name in &config.passthrough_env { if is_reserved_env_var(var_name) { tracing::debug!(%var_name, "skipping reserved passthrough_env variable"); @@ -532,6 +597,15 @@ impl Sandbox { } } + // Worker keyring isolation (Linux) — give the child a fresh empty + // session keyring even in passthrough (no sandbox) mode. + #[cfg(target_os = "linux")] + { + unsafe { + cmd.pre_exec(|| crate::secrets::keystore::pre_exec_new_session_keyring()); + } + } + cmd } diff --git a/src/secrets.rs b/src/secrets.rs index f388b9ba8..c24bb26be 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -1,4 +1,5 @@ -//! Encrypted secrets storage and output protection. +//! Credential storage, output protection, and OS keystore integration. +pub mod keystore; pub mod scrub; pub mod store; diff --git a/src/secrets/keystore.rs b/src/secrets/keystore.rs new file mode 100644 index 000000000..c1ab1058c --- /dev/null +++ b/src/secrets/keystore.rs @@ -0,0 +1,306 @@ +//! OS credential store abstraction for master key storage. +//! +//! The master key never exists as an environment variable or a file on disk. +//! It lives in kernel-level credential storage that is inaccessible to worker +//! subprocesses regardless of sandbox state. +//! +//! - **macOS:** Keychain via the Security framework. Access controlled by code +//! signature — worker subprocesses (bash, python, etc.) are different binaries +//! and cannot retrieve the key. +//! - **Linux:** Kernel keyring via `keyctl`. The key lives in kernel memory, +//! scoped to a session keyring. Workers are spawned with a fresh empty session +//! keyring via `pre_exec`. + +use crate::error::SecretsError; + +/// Service name used for Keychain/keyring identification. +const SERVICE_NAME: &str = "sh.spacebot.master-key"; + +/// Trait for OS-level credential storage. +/// +/// Implementations must be Send + Sync for use in async contexts. +pub trait KeyStore: Send + Sync { + /// Store the master key for the given instance. + fn store_key(&self, instance_id: &str, key: &[u8]) -> Result<(), SecretsError>; + + /// Retrieve the master key for the given instance. + fn load_key(&self, instance_id: &str) -> Result>, SecretsError>; + + /// Remove the master key from the credential store. + fn delete_key(&self, instance_id: &str) -> Result<(), SecretsError>; +} + +/// Create the platform-appropriate keystore. +pub fn platform_keystore() -> Box { + #[cfg(target_os = "macos")] + { + Box::new(MacOSKeyStore) + } + #[cfg(target_os = "linux")] + { + Box::new(LinuxKeyStore) + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + tracing::warn!("no OS keystore available on this platform — master key storage disabled"); + Box::new(NoopKeyStore) + } +} + +#[cfg(target_os = "macos")] +pub struct MacOSKeyStore; + +#[cfg(target_os = "macos")] +impl KeyStore for MacOSKeyStore { + fn store_key(&self, instance_id: &str, key: &[u8]) -> Result<(), SecretsError> { + use security_framework::passwords::{delete_generic_password, set_generic_password}; + + // Delete any existing entry first (set_generic_password fails on duplicate). + let _ = delete_generic_password(SERVICE_NAME, instance_id); + + set_generic_password(SERVICE_NAME, instance_id, key) + .map_err(|error| SecretsError::Other(anyhow::anyhow!("keychain store failed: {error}"))) + } + + fn load_key(&self, instance_id: &str) -> Result>, SecretsError> { + use security_framework::passwords::get_generic_password; + + match get_generic_password(SERVICE_NAME, instance_id) { + Ok(data) => Ok(Some(data.to_vec())), + Err(error) => { + // errSecItemNotFound means no key stored — not an error. + let code = error.code(); + if code == -25300 { + // errSecItemNotFound + Ok(None) + } else { + Err(SecretsError::Other(anyhow::anyhow!( + "keychain load failed: {error}" + ))) + } + } + } + } + + fn delete_key(&self, instance_id: &str) -> Result<(), SecretsError> { + use security_framework::passwords::delete_generic_password; + + match delete_generic_password(SERVICE_NAME, instance_id) { + Ok(()) => Ok(()), + Err(error) => { + let code = error.code(); + if code == -25300 { + Ok(()) // Already gone + } else { + Err(SecretsError::Other(anyhow::anyhow!( + "keychain delete failed: {error}" + ))) + } + } + } + } +} + +#[cfg(target_os = "linux")] +pub struct LinuxKeyStore; + +#[cfg(target_os = "linux")] +impl KeyStore for LinuxKeyStore { + fn store_key(&self, instance_id: &str, key: &[u8]) -> Result<(), SecretsError> { + let description = format!("{SERVICE_NAME}:{instance_id}"); + + // Get the session keyring. + let session_keyring = unsafe { + libc::syscall( + libc::SYS_keyctl, + 0x0b_i64, // KEYCTL_GET_KEYRING_ID + -3_i64, // KEY_SPEC_SESSION_KEYRING + 0_i64, // don't create + ) + }; + if session_keyring < 0 { + return Err(SecretsError::Other(anyhow::anyhow!( + "failed to get session keyring: {}", + std::io::Error::last_os_error() + ))); + } + + // Add the key. If it already exists with the same description, + // this replaces it (add_key with same type+description = update). + let result = unsafe { + libc::syscall( + libc::SYS_add_key, + b"user\0".as_ptr(), + description.as_ptr(), + key.as_ptr(), + key.len(), + session_keyring, + ) + }; + if result < 0 { + return Err(SecretsError::Other(anyhow::anyhow!( + "keyctl add_key failed: {}", + std::io::Error::last_os_error() + ))); + } + + Ok(()) + } + + fn load_key(&self, instance_id: &str) -> Result>, SecretsError> { + let description = format!("{SERVICE_NAME}:{instance_id}"); + + // Search the session keyring for a "user" type key with our description. + let key_id = unsafe { + libc::syscall( + libc::SYS_keyctl, + 0x0a_i64, // KEYCTL_SEARCH + -3_i64, // KEY_SPEC_SESSION_KEYRING + b"user\0".as_ptr(), + description.as_ptr(), + 0_i64, // don't link to a destination keyring + ) + }; + if key_id < 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENOKEY) { + return Ok(None); + } + return Err(SecretsError::Other(anyhow::anyhow!( + "keyctl search failed: {error}" + ))); + } + + // Read the key payload. + // First call with null buffer to get the size. + let size = unsafe { + libc::syscall( + libc::SYS_keyctl, + 0x0b_i64, // KEYCTL_READ + key_id, + std::ptr::null::(), + 0_usize, + ) + }; + if size < 0 { + return Err(SecretsError::Other(anyhow::anyhow!( + "keyctl read (size) failed: {}", + std::io::Error::last_os_error() + ))); + } + + let mut buffer = vec![0u8; size as usize]; + let read = unsafe { + libc::syscall( + libc::SYS_keyctl, + 0x0b_i64, // KEYCTL_READ + key_id, + buffer.as_mut_ptr(), + buffer.len(), + ) + }; + if read < 0 { + return Err(SecretsError::Other(anyhow::anyhow!( + "keyctl read failed: {}", + std::io::Error::last_os_error() + ))); + } + + buffer.truncate(read as usize); + Ok(Some(buffer)) + } + + fn delete_key(&self, instance_id: &str) -> Result<(), SecretsError> { + let description = format!("{SERVICE_NAME}:{instance_id}"); + + // Find the key first. + let key_id = unsafe { + libc::syscall( + libc::SYS_keyctl, + 0x0a_i64, // KEYCTL_SEARCH + -3_i64, // KEY_SPEC_SESSION_KEYRING + b"user\0".as_ptr(), + description.as_ptr(), + 0_i64, + ) + }; + if key_id < 0 { + // Not found — already deleted. + return Ok(()); + } + + // Invalidate the key. + let result = unsafe { + libc::syscall( + libc::SYS_keyctl, + 0x15_i64, // KEYCTL_INVALIDATE + key_id, + ) + }; + if result < 0 { + // KEYCTL_INVALIDATE may not be available on older kernels. + // Fall back to KEYCTL_REVOKE. + let revoke = unsafe { + libc::syscall( + libc::SYS_keyctl, + 0x03_i64, // KEYCTL_REVOKE + key_id, + ) + }; + if revoke < 0 { + return Err(SecretsError::Other(anyhow::anyhow!( + "keyctl revoke failed: {}", + std::io::Error::last_os_error() + ))); + } + } + + Ok(()) + } +} + +/// Spawn a worker subprocess with a fresh empty session keyring (Linux only). +/// +/// Must be called via `Command::pre_exec()` before `spawn()`. The child gets +/// a new session keyring and cannot access the parent's keyring (which holds +/// the master key). +/// +/// # Safety +/// +/// This function is intended to be called from `pre_exec` which runs between +/// fork and exec — only async-signal-safe operations are permitted. `keyctl` +/// is a direct syscall and is safe in this context. +#[cfg(target_os = "linux")] +pub unsafe fn pre_exec_new_session_keyring() -> std::io::Result<()> { + // KEYCTL_JOIN_SESSION_KEYRING with NULL name creates a new anonymous + // session keyring for this process. + let result = libc::syscall( + libc::SYS_keyctl, + 0x01_i64, // KEYCTL_JOIN_SESSION_KEYRING + std::ptr::null::(), + ); + if result < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +struct NoopKeyStore; + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +impl KeyStore for NoopKeyStore { + fn store_key(&self, _instance_id: &str, _key: &[u8]) -> Result<(), SecretsError> { + Err(SecretsError::Other(anyhow::anyhow!( + "no OS keystore available on this platform" + ))) + } + + fn load_key(&self, _instance_id: &str) -> Result>, SecretsError> { + Ok(None) + } + + fn delete_key(&self, _instance_id: &str) -> Result<(), SecretsError> { + Ok(()) + } +} diff --git a/src/secrets/scrub.rs b/src/secrets/scrub.rs index 647f43a7e..e601cdf28 100644 --- a/src/secrets/scrub.rs +++ b/src/secrets/scrub.rs @@ -190,6 +190,34 @@ impl StreamScrubber { } } +/// Scrub all tool secret values from a text string in one pass. +/// +/// Convenience wrapper over `StreamScrubber` for non-streaming content (worker +/// results, branch conclusions, status text). Replaces each tool secret value +/// with `[REDACTED:]`. +pub fn scrub_secrets(text: &str, tool_secrets: &[(String, String)]) -> String { + if tool_secrets.is_empty() { + return text.to_string(); + } + let mut result = text.to_string(); + for (name, value) in tool_secrets { + if !value.is_empty() { + result = result.replace(value.as_str(), &format!("[REDACTED:{name}]")); + } + } + result +} + +/// Scrub tool secret values from text using a `SecretsStore`. +/// +/// Reads the current tool secrets from the store and performs exact-match +/// redaction. For use in result paths where a `SecretsStore` reference is +/// available. +pub fn scrub_with_store(text: &str, store: &crate::secrets::store::SecretsStore) -> String { + let pairs = store.tool_secret_pairs(); + scrub_secrets(text, &pairs) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/secrets/store.rs b/src/secrets/store.rs index d598be95d..0039f6e63 100644 --- a/src/secrets/store.rs +++ b/src/secrets/store.rs @@ -1,18 +1,56 @@ -//! Encrypted credentials storage (AES-256-GCM, redb). +//! Credential storage with two secret categories: system secrets (internal, never +//! exposed to subprocesses) and tool secrets (passed to worker subprocesses as env +//! vars). +//! +//! Supports two modes: +//! - **Unencrypted (default):** secrets stored as plaintext in redb. No master key +//! needed. All secret store features work (categories, env sanitization, output +//! scrubbing). Only encryption at rest is missing. +//! - **Encrypted (opt-in):** AES-256-GCM with a master key derived via Argon2id. +//! The master key lives in the OS credential store (Keychain / kernel keyring), +//! never on disk. use crate::error::SecretsError; use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead}; use rand::RngCore; use redb::{Database, ReadableTable, TableDefinition}; -use sha2::{Digest, Sha256}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fmt::{Debug, Display, Formatter}; use std::path::Path; +use std::sync::RwLock; +/// Table for secret values. Stores either plaintext UTF-8 or nonce+ciphertext +/// depending on the store's encryption mode. const SECRETS_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("secrets"); +/// Table for secret metadata (category, timestamps). Always stored as plaintext +/// JSON so names and categories are readable even when the store is locked. +const METADATA_TABLE: TableDefinition<&str, &str> = TableDefinition::new("secrets_metadata"); + +/// Table for store-level configuration (encryption flag, argon2 salt, etc.). +const STORE_CONFIG_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("secrets_config"); + +/// Key in STORE_CONFIG_TABLE indicating whether encryption is enabled. +const CONFIG_KEY_ENCRYPTED: &str = "encrypted"; + +/// Key in STORE_CONFIG_TABLE storing the Argon2id salt (16 bytes). +const CONFIG_KEY_SALT: &str = "argon2_salt"; + +/// Key in STORE_CONFIG_TABLE storing a sentinel value encrypted with the master +/// key. Used to validate the key on unlock without decrypting every secret. +const CONFIG_KEY_SENTINEL: &str = "sentinel"; + +/// The plaintext sentinel value. Encrypted during `enable_encryption()` and +/// verified during `unlock()`. +const SENTINEL_PLAINTEXT: &[u8] = b"spacebot-secrets-sentinel-v1"; + +/// Secret value wrapper that redacts in Debug and Display to prevent accidental +/// logging of credential values. pub struct DecryptedSecret(String); impl DecryptedSecret { + /// Access the raw secret value. pub fn expose(&self) -> &str { &self.0 } @@ -30,181 +68,1549 @@ impl Display for DecryptedSecret { } } +/// Secret category determines subprocess exposure. +/// +/// All secrets are readable by Rust code via `SecretsStore::get()` regardless +/// of category. The category answers one question: should this value be injected +/// as an env var into worker subprocesses? +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SecretCategory { + /// Not exposed to subprocesses. Rust code reads them for internal use + /// (LLM clients, messaging adapters, webhook integrations). + System, + /// Exposed to subprocesses as env vars. CLI tools workers invoke need these + /// (gh, npm, aws, etc.). Rust code can also read them. + Tool, +} + +impl std::fmt::Display for SecretCategory { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + SecretCategory::System => write!(f, "system"), + SecretCategory::Tool => write!(f, "tool"), + } + } +} + +/// Metadata stored alongside each secret (always unencrypted). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecretMetadata { + pub category: SecretCategory, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +/// Store state exposed via the status API. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StoreState { + /// Secrets stored in plaintext in redb. No master key configured. + Unencrypted, + /// Encryption enabled, master key loaded. Secrets are decrypted and operational. + Unlocked, + /// Encryption enabled but master key not available. Encrypted secrets are + /// inaccessible. Happens after Linux reboot (kernel keyring cleared). + Locked, +} + +impl std::fmt::Display for StoreState { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + StoreState::Unencrypted => write!(f, "unencrypted"), + StoreState::Unlocked => write!(f, "unlocked"), + StoreState::Locked => write!(f, "locked"), + } + } +} + +/// Status snapshot for the API. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoreStatus { + pub state: StoreState, + pub encrypted: bool, + pub secret_count: usize, + pub system_count: usize, + pub tool_count: usize, + pub platform_managed: bool, +} + +/// Credential store with system/tool categorization and optional encryption. +/// +/// Created once per agent, shared via `Arc`. Thread-safe — internal `RwLock` +/// protects the cipher state for encryption transitions. pub struct SecretsStore { db: Database, + /// Current cipher state. `None` when unencrypted or locked. + /// Protected by RwLock for encrypt/unlock/lock transitions. + cipher_state: RwLock>, + /// Whether the redb store has encrypted secrets (persisted flag). + encrypted: RwLock, +} + +/// Derived cipher key + salt for the encrypted mode. +struct CipherState { + cipher: Aes256Gcm, + #[allow(dead_code)] + salt: [u8; 16], +} + +impl Debug for SecretsStore { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SecretsStore") + .field("state", &self.state()) + .finish() + } } impl SecretsStore { + /// Open or create a secrets store at the given path. + /// + /// The store starts in unencrypted mode. If the redb file contains the + /// `encrypted` flag, the store starts in locked state until `unlock()` is + /// called with the master key. pub fn new(path: impl AsRef) -> Result { let db = Database::create(path.as_ref()).map_err(|error| { SecretsError::Other(anyhow::anyhow!("failed to open secrets database: {error}")) })?; + // Ensure all tables exist. let write_transaction = db.begin_write().map_err(|error| { SecretsError::Other(anyhow::anyhow!( - "failed to initialize secrets table transaction: {error}" + "failed to initialize secrets tables: {error}" )) })?; { - let _table = write_transaction + let _secrets = write_transaction .open_table(SECRETS_TABLE) .map_err(|error| { SecretsError::Other(anyhow::anyhow!("failed to open secrets table: {error}")) })?; + let _metadata = write_transaction + .open_table(METADATA_TABLE) + .map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to open metadata table: {error}")) + })?; + let _config = write_transaction + .open_table(STORE_CONFIG_TABLE) + .map_err(|error| { + SecretsError::Other(anyhow::anyhow!( + "failed to open store config table: {error}" + )) + })?; } write_transaction.commit().map_err(|error| { SecretsError::Other(anyhow::anyhow!( - "failed to commit secrets table initialization: {error}" + "failed to commit table initialization: {error}" )) })?; - Ok(Self { db }) + // Check if encryption was previously enabled. + let encrypted = { + let read_txn = db.begin_read().map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to begin read transaction: {error}")) + })?; + let table = read_txn.open_table(STORE_CONFIG_TABLE).map_err(|error| { + SecretsError::Other(anyhow::anyhow!( + "failed to open store config table: {error}" + )) + })?; + match table.get(CONFIG_KEY_ENCRYPTED) { + Ok(Some(value)) => value.value() == [1], + _ => false, + } + }; + + Ok(Self { + db, + cipher_state: RwLock::new(None), + encrypted: RwLock::new(encrypted), + }) + } + + /// Current store state. + pub fn state(&self) -> StoreState { + let encrypted = *self.encrypted.read().expect("encrypted lock poisoned"); + if !encrypted { + return StoreState::Unencrypted; + } + if self + .cipher_state + .read() + .expect("cipher lock poisoned") + .is_some() + { + StoreState::Unlocked + } else { + StoreState::Locked + } } - pub fn set(&self, key: &str, value: &str, master_key: &[u8]) -> Result<(), SecretsError> { - let cipher = build_cipher(master_key)?; + /// Whether the store has encryption enabled. + pub fn is_encrypted(&self) -> bool { + *self.encrypted.read().expect("encrypted lock poisoned") + } - let mut nonce_bytes = [0_u8; 12]; - rand::rng().fill_bytes(&mut nonce_bytes); - let nonce = Nonce::from_slice(&nonce_bytes); + /// Full status snapshot for the API. + pub fn status(&self, platform_managed: bool) -> Result { + let all_metadata = self.list_metadata()?; + let system_count = all_metadata + .values() + .filter(|m| m.category == SecretCategory::System) + .count(); + let tool_count = all_metadata + .values() + .filter(|m| m.category == SecretCategory::Tool) + .count(); - let ciphertext = cipher - .encrypt(nonce, value.as_bytes()) - .map_err(|error| SecretsError::EncryptionFailed(error.to_string()))?; + Ok(StoreStatus { + state: self.state(), + encrypted: self.is_encrypted(), + secret_count: all_metadata.len(), + system_count, + tool_count, + platform_managed, + }) + } - let mut stored_value = Vec::with_capacity(nonce_bytes.len() + ciphertext.len()); - stored_value.extend_from_slice(&nonce_bytes); - stored_value.extend_from_slice(&ciphertext); + /// Store a secret with the given category. Creates or updates. + pub fn set( + &self, + name: &str, + value: &str, + category: SecretCategory, + ) -> Result<(), SecretsError> { + let state = self.state(); + if state == StoreState::Locked { + return Err(SecretsError::Other(anyhow::anyhow!( + "secret store is locked — unlock with master key first" + ))); + } - let write_transaction = self.db.begin_write().map_err(|error| { + let stored_value = self.encode_value(value)?; + let now = chrono::Utc::now(); + + // Check if updating an existing secret (preserve created_at). + let existing_meta = self.get_metadata(name).ok(); + let metadata = SecretMetadata { + category, + created_at: existing_meta.as_ref().map(|m| m.created_at).unwrap_or(now), + updated_at: now, + }; + let metadata_json = serde_json::to_string(&metadata).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to serialize metadata: {error}")) + })?; + + let write_txn = self.db.begin_write().map_err(|error| { SecretsError::Other(anyhow::anyhow!( "failed to begin write transaction: {error}" )) })?; - { - let mut table = write_transaction - .open_table(SECRETS_TABLE) + let mut secrets = write_txn.open_table(SECRETS_TABLE).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to open secrets table: {error}")) + })?; + secrets + .insert(name, stored_value.as_slice()) .map_err(|error| { - SecretsError::Other(anyhow::anyhow!("failed to open secrets table: {error}")) + SecretsError::Other(anyhow::anyhow!( + "failed to insert secret '{name}': {error}" + )) })?; - table - .insert(key, stored_value.as_slice()) - .map_err(|error| { - SecretsError::Other(anyhow::anyhow!("failed to insert secret '{key}': {error}")) - })?; + let mut meta = write_txn.open_table(METADATA_TABLE).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to open metadata table: {error}")) + })?; + meta.insert(name, metadata_json.as_str()).map_err(|error| { + SecretsError::Other(anyhow::anyhow!( + "failed to insert metadata for '{name}': {error}" + )) + })?; } - - write_transaction.commit().map_err(|error| { - SecretsError::Other(anyhow::anyhow!("failed to commit secret '{key}': {error}")) + write_txn.commit().map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to commit secret '{name}': {error}")) })?; Ok(()) } - pub fn get(&self, key: &str, master_key: &[u8]) -> Result { - let cipher = build_cipher(master_key)?; + /// Retrieve a decrypted secret value. + pub fn get(&self, name: &str) -> Result { + let state = self.state(); + if state == StoreState::Locked { + return Err(SecretsError::Other(anyhow::anyhow!( + "secret store is locked — unlock with master key first" + ))); + } - let read_transaction = self.db.begin_read().map_err(|error| { + let read_txn = self.db.begin_read().map_err(|error| { SecretsError::Other(anyhow::anyhow!("failed to begin read transaction: {error}")) })?; - let table = read_transaction - .open_table(SECRETS_TABLE) + let table = read_txn.open_table(SECRETS_TABLE).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to open secrets table: {error}")) + })?; + + let value = table + .get(name) .map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to read key '{name}': {error}")) + })? + .ok_or_else(|| SecretsError::NotFound { + key: name.to_string(), + })?; + + let raw = value.value(); + self.decode_value(raw) + } + + /// Delete a secret. + pub fn delete(&self, name: &str) -> Result<(), SecretsError> { + let write_txn = self.db.begin_write().map_err(|error| { + SecretsError::Other(anyhow::anyhow!( + "failed to begin write transaction: {error}" + )) + })?; + { + let mut secrets = write_txn.open_table(SECRETS_TABLE).map_err(|error| { SecretsError::Other(anyhow::anyhow!("failed to open secrets table: {error}")) })?; + secrets.remove(name).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to remove key '{name}': {error}")) + })?; + + let mut meta = write_txn.open_table(METADATA_TABLE).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to open metadata table: {error}")) + })?; + meta.remove(name).map_err(|error| { + SecretsError::Other(anyhow::anyhow!( + "failed to remove metadata for '{name}': {error}" + )) + })?; + } + write_txn.commit().map_err(|error| { + SecretsError::Other(anyhow::anyhow!( + "failed to commit delete for '{name}': {error}" + )) + })?; + + Ok(()) + } + + /// List all secret names. + pub fn list(&self) -> Result, SecretsError> { + let read_txn = self.db.begin_read().map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to begin read transaction: {error}")) + })?; + let table = read_txn.open_table(METADATA_TABLE).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to open metadata table: {error}")) + })?; + + let mut names = Vec::new(); + let iter = table.iter().map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to iterate metadata table: {error}")) + })?; + for entry in iter { + let (key, _) = entry.map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to read metadata entry: {error}")) + })?; + names.push(key.value().to_string()); + } + + Ok(names) + } + + /// Get metadata for a specific secret. + pub fn get_metadata(&self, name: &str) -> Result { + let read_txn = self.db.begin_read().map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to begin read transaction: {error}")) + })?; + let table = read_txn.open_table(METADATA_TABLE).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to open metadata table: {error}")) + })?; let value = table - .get(key) + .get(name) .map_err(|error| { - SecretsError::Other(anyhow::anyhow!("failed to read key '{key}': {error}")) + SecretsError::Other(anyhow::anyhow!( + "failed to read metadata for '{name}': {error}" + )) })? .ok_or_else(|| SecretsError::NotFound { - key: key.to_string(), + key: name.to_string(), })?; - let encrypted_value = value.value(); - if encrypted_value.len() < 12 { - return Err(SecretsError::DecryptionFailed( - "stored secret is missing nonce prefix".to_string(), - )); + serde_json::from_str(value.value()).map_err(|error| { + SecretsError::Other(anyhow::anyhow!( + "failed to parse metadata for '{name}': {error}" + )) + }) + } + + /// List all secrets with their metadata. Works in all states (names and + /// categories are stored as unencrypted metadata). + pub fn list_metadata(&self) -> Result, SecretsError> { + let read_txn = self.db.begin_read().map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to begin read transaction: {error}")) + })?; + let table = read_txn.open_table(METADATA_TABLE).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to open metadata table: {error}")) + })?; + + let mut result = HashMap::new(); + let iter = table.iter().map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to iterate metadata table: {error}")) + })?; + for entry in iter { + let (key, value) = entry.map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to read metadata entry: {error}")) + })?; + if let Ok(meta) = serde_json::from_str::(value.value()) { + result.insert(key.value().to_string(), meta); + } + } + + Ok(result) + } + + /// Get all tool secrets as name→value pairs for `Sandbox::wrap()` injection. + /// + /// Returns an empty map when locked (tool secrets unavailable). + pub fn tool_env_vars(&self) -> HashMap { + if self.state() == StoreState::Locked { + return HashMap::new(); } - let nonce = Nonce::from_slice(&encrypted_value[..12]); - let ciphertext = &encrypted_value[12..]; - let plaintext = cipher - .decrypt(nonce, ciphertext) - .map_err(|error| SecretsError::DecryptionFailed(error.to_string()))?; + let metadata = match self.list_metadata() { + Ok(m) => m, + Err(error) => { + tracing::warn!(%error, "failed to list secret metadata for tool env vars"); + return HashMap::new(); + } + }; + + let mut result = HashMap::new(); + for (name, meta) in &metadata { + if meta.category == SecretCategory::Tool + && let Ok(secret) = self.get(name) + { + result.insert(name.clone(), secret.expose().to_string()); + } + } + + result + } - let plaintext = String::from_utf8(plaintext) - .map_err(|error| SecretsError::DecryptionFailed(error.to_string()))?; + /// Get names of all tool secrets for injection into worker system prompts. + /// + /// Returns names even when locked (metadata is always accessible). + pub fn tool_secret_names(&self) -> Vec { + match self.list_metadata() { + Ok(metadata) => metadata + .into_iter() + .filter(|(_, meta)| meta.category == SecretCategory::Tool) + .map(|(name, _)| name) + .collect(), + Err(error) => { + tracing::warn!(%error, "failed to list secret metadata for tool secret names"); + Vec::new() + } + } + } - Ok(DecryptedSecret(plaintext)) + /// Get all tool secret name→value pairs for the output scrubber. + /// + /// Returns pairs suitable for `StreamScrubber::new()`. + pub fn tool_secret_pairs(&self) -> Vec<(String, String)> { + self.tool_env_vars().into_iter().collect() } - pub fn delete(&self, key: &str) -> Result<(), SecretsError> { - let write_transaction = self.db.begin_write().map_err(|error| { + /// Enable encryption. Generates a random master key, encrypts all existing + /// secrets in place, stores the key derivation salt and sentinel in redb. + /// + /// Returns the raw master key bytes for the caller to store in the OS + /// credential store and display to the user. + pub fn enable_encryption(&self) -> Result, SecretsError> { + if self.is_encrypted() { + return Err(SecretsError::Other(anyhow::anyhow!( + "encryption is already enabled" + ))); + } + + // Generate master key and salt. + let mut master_key = vec![0u8; 32]; + rand::rng().fill_bytes(&mut master_key); + let mut salt = [0u8; 16]; + rand::rng().fill_bytes(&mut salt); + + // Derive cipher key. + let cipher = derive_cipher(&master_key, &salt)?; + + // Encrypt sentinel value. + let encrypted_sentinel = encrypt_bytes(&cipher, SENTINEL_PLAINTEXT)?; + + // Re-encrypt all existing secrets. + let names = self.list()?; + let mut plain_values: Vec<(String, Vec)> = Vec::with_capacity(names.len()); + { + let read_txn = self.db.begin_read().map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to begin read transaction: {error}")) + })?; + let table = read_txn.open_table(SECRETS_TABLE).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to open secrets table: {error}")) + })?; + for name in &names { + if let Some(val) = table.get(name.as_str()).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to read secret '{name}': {error}")) + })? { + // Current values are plaintext UTF-8. + plain_values.push((name.clone(), val.value().to_vec())); + } + } + } + + // Write everything in one transaction. + let write_txn = self.db.begin_write().map_err(|error| { SecretsError::Other(anyhow::anyhow!( "failed to begin write transaction: {error}" )) })?; - { - let mut table = write_transaction - .open_table(SECRETS_TABLE) + let mut secrets = write_txn.open_table(SECRETS_TABLE).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to open secrets table: {error}")) + })?; + // Re-encrypt each secret value. + for (name, plaintext_bytes) in &plain_values { + let encrypted = encrypt_bytes(&cipher, plaintext_bytes)?; + secrets + .insert(name.as_str(), encrypted.as_slice()) + .map_err(|error| { + SecretsError::Other(anyhow::anyhow!( + "failed to write encrypted secret '{name}': {error}" + )) + })?; + } + + let mut config = write_txn.open_table(STORE_CONFIG_TABLE).map_err(|error| { + SecretsError::Other(anyhow::anyhow!( + "failed to open store config table: {error}" + )) + })?; + config + .insert(CONFIG_KEY_ENCRYPTED, &[1u8][..]) .map_err(|error| { - SecretsError::Other(anyhow::anyhow!("failed to open secrets table: {error}")) + SecretsError::Other(anyhow::anyhow!("failed to write encryption flag: {error}")) })?; - - table.remove(key).map_err(|error| { - SecretsError::Other(anyhow::anyhow!("failed to remove key '{key}': {error}")) + config.insert(CONFIG_KEY_SALT, &salt[..]).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to write salt: {error}")) })?; + config + .insert(CONFIG_KEY_SENTINEL, encrypted_sentinel.as_slice()) + .map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to write sentinel: {error}")) + })?; } - - write_transaction.commit().map_err(|error| { + write_txn.commit().map_err(|error| { SecretsError::Other(anyhow::anyhow!( - "failed to commit delete for '{key}': {error}" + "failed to commit encryption enablement: {error}" )) })?; + // Update in-memory state. + *self.encrypted.write().expect("encrypted lock poisoned") = true; + *self.cipher_state.write().expect("cipher lock poisoned") = + Some(CipherState { cipher, salt }); + + Ok(master_key) + } + + /// Unlock the store with the given master key. Validates against the stored + /// sentinel before accepting. + pub fn unlock(&self, master_key: &[u8]) -> Result<(), SecretsError> { + if !self.is_encrypted() { + return Err(SecretsError::Other(anyhow::anyhow!( + "store is not encrypted — nothing to unlock" + ))); + } + if self.state() == StoreState::Unlocked { + return Err(SecretsError::Other(anyhow::anyhow!( + "store is already unlocked" + ))); + } + + // Read salt and sentinel from redb. + let (salt, encrypted_sentinel) = { + let read_txn = self.db.begin_read().map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to begin read transaction: {error}")) + })?; + let table = read_txn.open_table(STORE_CONFIG_TABLE).map_err(|error| { + SecretsError::Other(anyhow::anyhow!( + "failed to open store config table: {error}" + )) + })?; + + let salt_val = table + .get(CONFIG_KEY_SALT) + .map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to read salt: {error}")) + })? + .ok_or_else(|| { + SecretsError::Other(anyhow::anyhow!("encrypted store missing salt")) + })?; + let mut salt = [0u8; 16]; + let raw = salt_val.value(); + if raw.len() != 16 { + return Err(SecretsError::Other(anyhow::anyhow!( + "invalid salt length: {}", + raw.len() + ))); + } + salt.copy_from_slice(raw); + + let sentinel_val = table + .get(CONFIG_KEY_SENTINEL) + .map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to read sentinel: {error}")) + })? + .ok_or_else(|| { + SecretsError::Other(anyhow::anyhow!("encrypted store missing sentinel")) + })?; + + (salt, sentinel_val.value().to_vec()) + }; + + // Derive cipher and verify sentinel. + let cipher = derive_cipher(master_key, &salt)?; + let decrypted_sentinel = + decrypt_bytes(&cipher, &encrypted_sentinel).map_err(|_| SecretsError::InvalidKey)?; + + if decrypted_sentinel != SENTINEL_PLAINTEXT { + return Err(SecretsError::InvalidKey); + } + + // Accept the key. + *self.cipher_state.write().expect("cipher lock poisoned") = + Some(CipherState { cipher, salt }); + Ok(()) } - pub fn list(&self) -> Result, SecretsError> { - let read_transaction = self.db.begin_read().map_err(|error| { - SecretsError::Other(anyhow::anyhow!("failed to begin read transaction: {error}")) + /// Lock the store. Clears the in-memory cipher key. Encrypted secrets become + /// inaccessible until `unlock()` is called again. + pub fn lock(&self) -> Result<(), SecretsError> { + if !self.is_encrypted() { + return Err(SecretsError::Other(anyhow::anyhow!( + "store is not encrypted — nothing to lock" + ))); + } + *self.cipher_state.write().expect("cipher lock poisoned") = None; + Ok(()) + } + + /// Rotate the master key. Generates a new key, re-encrypts all secrets and + /// the sentinel, updates the salt. Returns the new master key bytes. + pub fn rotate_key(&self) -> Result, SecretsError> { + if self.state() != StoreState::Unlocked { + return Err(SecretsError::Other(anyhow::anyhow!( + "store must be unlocked to rotate key" + ))); + } + + // Generate new key and salt. + let mut new_master_key = vec![0u8; 32]; + rand::rng().fill_bytes(&mut new_master_key); + let mut new_salt = [0u8; 16]; + rand::rng().fill_bytes(&mut new_salt); + + let new_cipher = derive_cipher(&new_master_key, &new_salt)?; + + // Decrypt all secrets with old cipher, re-encrypt with new. + let names = self.list()?; + let mut re_encrypted: Vec<(String, Vec)> = Vec::with_capacity(names.len()); + for name in &names { + let decrypted = self.get(name)?; + let encrypted = encrypt_bytes(&new_cipher, decrypted.expose().as_bytes())?; + re_encrypted.push((name.clone(), encrypted)); + } + + // Re-encrypt sentinel. + let new_encrypted_sentinel = encrypt_bytes(&new_cipher, SENTINEL_PLAINTEXT)?; + + // Write everything atomically. + let write_txn = self.db.begin_write().map_err(|error| { + SecretsError::Other(anyhow::anyhow!( + "failed to begin write transaction: {error}" + )) })?; - let table = read_transaction - .open_table(SECRETS_TABLE) - .map_err(|error| { + { + let mut secrets = write_txn.open_table(SECRETS_TABLE).map_err(|error| { SecretsError::Other(anyhow::anyhow!("failed to open secrets table: {error}")) })?; + for (name, encrypted) in &re_encrypted { + secrets + .insert(name.as_str(), encrypted.as_slice()) + .map_err(|error| { + SecretsError::Other(anyhow::anyhow!( + "failed to write re-encrypted secret '{name}': {error}" + )) + })?; + } - let mut keys = Vec::new(); - let iter = table.iter().map_err(|error| { - SecretsError::Other(anyhow::anyhow!("failed to iterate secrets table: {error}")) + let mut config = write_txn.open_table(STORE_CONFIG_TABLE).map_err(|error| { + SecretsError::Other(anyhow::anyhow!( + "failed to open store config table: {error}" + )) + })?; + config + .insert(CONFIG_KEY_SALT, &new_salt[..]) + .map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to write new salt: {error}")) + })?; + config + .insert(CONFIG_KEY_SENTINEL, new_encrypted_sentinel.as_slice()) + .map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to write new sentinel: {error}")) + })?; + } + write_txn.commit().map_err(|error| { + SecretsError::Other(anyhow::anyhow!("failed to commit key rotation: {error}")) })?; - for entry in iter { - let (key, _value) = entry.map_err(|error| { - SecretsError::Other(anyhow::anyhow!("failed to read secrets entry: {error}")) - })?; - keys.push(key.value().to_string()); + // Update in-memory cipher. + *self.cipher_state.write().expect("cipher lock poisoned") = Some(CipherState { + cipher: new_cipher, + salt: new_salt, + }); + + Ok(new_master_key) + } + + /// Check if a secret exists. + pub fn exists(&self, name: &str) -> bool { + self.get_metadata(name).is_ok() + } + + /// Export all secrets as a portable backup. + /// + /// Returns a JSON blob with all secret names, values, categories, and + /// metadata. If encryption is enabled and the store is unlocked, the + /// export contains decrypted values (the caller can re-encrypt the file + /// at rest). If the store is locked, returns an error. + pub fn export_all(&self) -> Result { + if self.state() == StoreState::Locked { + return Err(SecretsError::Other(anyhow::anyhow!( + "secret store is locked — unlock before exporting" + ))); } - Ok(keys) + let metadata = self.list_metadata()?; + let mut entries = Vec::with_capacity(metadata.len()); + + for (name, meta) in &metadata { + let value = self.get(name)?; + entries.push(ExportEntry { + name: name.clone(), + value: value.expose().to_string(), + category: meta.category, + created_at: meta.created_at, + updated_at: meta.updated_at, + }); + } + + entries.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(ExportData { + version: 1, + encrypted: self.is_encrypted(), + entries, + }) } + + /// Import secrets from an export. Optionally overwrites existing secrets + /// with the same name. + /// + /// Returns the count of imported and skipped secrets. + pub fn import_all( + &self, + data: &ExportData, + overwrite: bool, + ) -> Result { + if self.state() == StoreState::Locked { + return Err(SecretsError::Other(anyhow::anyhow!( + "secret store is locked — unlock before importing" + ))); + } + + let mut imported = 0usize; + let mut skipped = Vec::new(); + + for entry in &data.entries { + if self.exists(&entry.name) && !overwrite { + skipped.push(entry.name.clone()); + continue; + } + + self.set(&entry.name, &entry.value, entry.category)?; + imported += 1; + } + + Ok(ImportResult { imported, skipped }) + } + + /// Encode a plaintext value for storage. In encrypted mode, encrypts with + /// the current cipher. In unencrypted mode, stores as raw UTF-8 bytes. + fn encode_value(&self, plaintext: &str) -> Result, SecretsError> { + let guard = self.cipher_state.read().expect("cipher lock poisoned"); + match guard.as_ref() { + Some(state) => encrypt_bytes(&state.cipher, plaintext.as_bytes()), + None => { + // Unencrypted mode — store as raw UTF-8. + Ok(plaintext.as_bytes().to_vec()) + } + } + } + + /// Decode a stored value. In encrypted mode, decrypts. In unencrypted mode, + /// interprets as raw UTF-8. + fn decode_value(&self, stored: &[u8]) -> Result { + let guard = self.cipher_state.read().expect("cipher lock poisoned"); + match guard.as_ref() { + Some(state) => { + let plaintext = decrypt_bytes(&state.cipher, stored)?; + let text = String::from_utf8(plaintext) + .map_err(|error| SecretsError::DecryptionFailed(error.to_string()))?; + Ok(DecryptedSecret(text)) + } + None => { + // Unencrypted mode — raw UTF-8. + let text = String::from_utf8(stored.to_vec()) + .map_err(|error| SecretsError::DecryptionFailed(error.to_string()))?; + Ok(DecryptedSecret(text)) + } + } + } +} + +/// Portable backup format for all secrets in a store. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportData { + /// Format version (currently 1). + pub version: u32, + /// Whether the source store had encryption enabled (informational only — + /// values in this struct are always plaintext). + pub encrypted: bool, + /// All secrets with their metadata. + pub entries: Vec, +} + +/// A single secret in the export format. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportEntry { + pub name: String, + pub value: String, + pub category: SecretCategory, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, } -fn build_cipher(master_key: &[u8]) -> Result { +/// Result of an import operation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportResult { + /// Number of secrets successfully imported. + pub imported: usize, + /// Names of secrets that were skipped because they already existed (and + /// overwrite was not requested). + pub skipped: Vec, +} + +/// Derive an AES-256-GCM cipher key from a master key using Argon2id. +/// +/// Argon2id is used instead of SHA-256 because self-hosted users may use a +/// passphrase as the master key. SHA-256 of a passphrase is trivially brutable; +/// Argon2id is memory-hard and resistant to GPU/ASIC attacks. The cost is a +/// one-time ~100ms at startup. +fn derive_cipher(master_key: &[u8], salt: &[u8; 16]) -> Result { if master_key.is_empty() { return Err(SecretsError::InvalidKey); } - let mut hasher = Sha256::new(); - hasher.update(master_key); - let digest = hasher.finalize(); + let params = argon2::Params::new( + 64 * 1024, // 64 MiB memory + 3, // 3 iterations + 1, // 1 degree of parallelism + Some(32), // 32 bytes output + ) + .map_err(|error| SecretsError::Other(anyhow::anyhow!("argon2 params error: {error}")))?; + + let argon2 = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + + let mut derived_key = [0u8; 32]; + argon2 + .hash_password_into(master_key, salt, &mut derived_key) + .map_err(|error| { + SecretsError::Other(anyhow::anyhow!("argon2 key derivation failed: {error}")) + })?; + + Aes256Gcm::new_from_slice(&derived_key).map_err(|_| SecretsError::InvalidKey) +} + +/// Encrypt bytes with AES-256-GCM. Returns nonce (12 bytes) + ciphertext. +fn encrypt_bytes(cipher: &Aes256Gcm, plaintext: &[u8]) -> Result, SecretsError> { + let mut nonce_bytes = [0u8; 12]; + rand::rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|error| SecretsError::EncryptionFailed(error.to_string()))?; + + let mut stored = Vec::with_capacity(12 + ciphertext.len()); + stored.extend_from_slice(&nonce_bytes); + stored.extend_from_slice(&ciphertext); + Ok(stored) +} + +/// Decrypt nonce+ciphertext bytes with AES-256-GCM. +fn decrypt_bytes(cipher: &Aes256Gcm, stored: &[u8]) -> Result, SecretsError> { + if stored.len() < 12 { + return Err(SecretsError::DecryptionFailed( + "stored value too short for nonce".to_string(), + )); + } + let nonce = Nonce::from_slice(&stored[..12]); + let ciphertext = &stored[12..]; + cipher + .decrypt(nonce, ciphertext) + .map_err(|error| SecretsError::DecryptionFailed(error.to_string())) +} + +/// Naming pattern for adapter secret fields that support named instances. +/// +/// For a Discord adapter named `"alerts"` with pattern +/// `{ platform_prefix: "DISCORD", field_suffix: "BOT_TOKEN" }`, the derived +/// secret name is `"DISCORD_ALERTS_BOT_TOKEN"`. +#[derive(Debug, Clone, Copy)] +pub struct InstancePattern { + /// Platform prefix (e.g. `"DISCORD"`, `"SLACK"`). + pub platform_prefix: &'static str, + /// Field suffix (e.g. `"BOT_TOKEN"`, `"APP_TOKEN"`). + pub field_suffix: &'static str, +} + +/// A credential field declared by a config section. +/// +/// Used by both messaging adapters and other config sections (LLM providers, +/// search integrations) to declare which fields contain secrets. The secret +/// store uses these declarations for auto-categorization and migration. +/// +/// For fields with an [`InstancePattern`], named adapter instances derive +/// secret names by inserting the uppercased instance name: +/// +/// ```text +/// default: DISCORD_BOT_TOKEN +/// instance: DISCORD_ALERTS_BOT_TOKEN +/// ``` +#[derive(Debug, Clone, Copy)] +pub struct SecretField { + /// TOML key within the config section (e.g. `"token"`, `"anthropic_key"`). + pub toml_key: &'static str, + /// Canonical secret name (e.g. `"DISCORD_BOT_TOKEN"`, `"ANTHROPIC_API_KEY"`). + pub secret_name: &'static str, + /// Instance naming pattern. Only set for messaging adapters that support + /// `[[messaging.*.instances]]`. `None` for LLM keys, search keys, etc. + pub instance_pattern: Option, +} + +impl SecretField { + /// Secret name for a named adapter instance. + /// + /// Given instance name `"alerts"` and pattern `DISCORD / BOT_TOKEN`, + /// produces `"DISCORD_ALERTS_BOT_TOKEN"`. + /// + /// Returns `None` if this field has no instance pattern (e.g. LLM keys). + pub fn instance_name(&self, instance: &str) -> Option { + let pattern = self.instance_pattern.as_ref()?; + Some(format!( + "{}_{}_{}", + pattern.platform_prefix, + instance.to_uppercase(), + pattern.field_suffix + )) + } +} + +/// Trait for config sections to declare their credential fields. +/// +/// Implemented on messaging adapter configs (e.g. `DiscordConfig`), LLM config, +/// and any other config section that holds secrets. Used by the secret store for +/// auto-categorization (any matching name → System) and by the migration endpoint +/// to scan config.toml for plaintext credentials. +pub trait SystemSecrets { + /// Section identifier used for TOML path construction. + /// + /// For messaging adapters: `"discord"`, `"slack"`, etc. (paths are + /// `messaging.{section}.{toml_key}`). + /// For other sections: `"llm"`, `"defaults"`, etc. (paths are + /// `{section}.{toml_key}`). + fn section() -> &'static str; + + /// Credential fields this config section uses. + fn secret_fields() -> &'static [SecretField]; + + /// Whether this section is a messaging adapter (has `instances` array). + /// Default: `false`. + fn is_messaging_adapter() -> bool { + false + } +} + +/// Collect all system secret fields from every config section that implements +/// [`SystemSecrets`]. Returns the combined list used for auto-categorization +/// and migration. +/// +/// This is the single source of truth — adding a new secret-bearing config +/// section only requires implementing [`SystemSecrets`] and listing the type +/// here. +pub fn system_secret_registry() -> Vec<&'static SecretField> { + use crate::config::{ + DefaultsConfig, DiscordConfig, EmailConfig, LlmConfig, SlackConfig, TelegramConfig, + TwitchConfig, + }; + + let mut fields = Vec::new(); + // LLM provider keys. + fields.extend(LlmConfig::secret_fields()); + // Search / internal tool keys. + fields.extend(DefaultsConfig::secret_fields()); + // Messaging adapters. + fields.extend(DiscordConfig::secret_fields()); + fields.extend(SlackConfig::secret_fields()); + fields.extend(TelegramConfig::secret_fields()); + fields.extend(TwitchConfig::secret_fields()); + fields.extend(EmailConfig::secret_fields()); + fields +} + +/// Auto-detect the category for a secret based on its name. +/// +/// Secrets whose names match a known internal credential (LLM provider key, +/// messaging adapter token, etc.) are categorized as `System` — never exposed +/// to worker subprocesses. This includes: +/// +/// - Exact matches against any [`SystemSecrets`] field's `secret_name` +/// - Named instance patterns (e.g. `DISCORD_ALERTS_BOT_TOKEN`) — any name that +/// matches `{PREFIX}_{ANYTHING}_{SUFFIX}` for a known adapter field with an +/// [`InstancePattern`] +/// +/// Everything else defaults to `Tool` — exposed to workers as environment +/// variables. This is the safe default because user-added secrets are almost +/// always credentials for CLI tools that workers invoke. +pub fn auto_categorize(name: &str) -> SecretCategory { + let upper = name.to_uppercase(); - Aes256Gcm::new_from_slice(&digest).map_err(|_| SecretsError::InvalidKey) + for field in system_secret_registry() { + // Exact match on the canonical secret name. + if upper == field.secret_name { + return SecretCategory::System; + } + + // Named instance pattern: {PREFIX}_{ANYTHING}_{SUFFIX} + // e.g. DISCORD_ALERTS_BOT_TOKEN matches DISCORD + BOT_TOKEN + if let Some(pattern) = &field.instance_pattern { + let prefix = format!("{}_", pattern.platform_prefix); + let suffix = format!("_{}", pattern.field_suffix); + if upper.starts_with(&prefix) + && upper.ends_with(&suffix) + && upper.len() > prefix.len() + suffix.len() + { + return SecretCategory::System; + } + } + } + + // Unknown secrets default to tool — they're most likely credentials for + // CLI tools that workers need (gh, npm, aws, docker, cargo, etc.). + SecretCategory::Tool +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + fn temp_store() -> (SecretsStore, NamedTempFile) { + let file = NamedTempFile::new().expect("create temp file"); + let store = SecretsStore::new(file.path()).expect("create store"); + (store, file) + } + + #[test] + fn unencrypted_set_get_delete() { + let (store, _file) = temp_store(); + assert_eq!(store.state(), StoreState::Unencrypted); + + store + .set("MY_KEY", "my_value", SecretCategory::Tool) + .expect("set"); + let secret = store.get("MY_KEY").expect("get"); + assert_eq!(secret.expose(), "my_value"); + + let meta = store.get_metadata("MY_KEY").expect("metadata"); + assert_eq!(meta.category, SecretCategory::Tool); + + store.delete("MY_KEY").expect("delete"); + assert!(store.get("MY_KEY").is_err()); + } + + #[test] + fn tool_env_vars_returns_only_tool_secrets() { + let (store, _file) = temp_store(); + store + .set("ANTHROPIC_API_KEY", "sk-ant-xxx", SecretCategory::System) + .expect("set system"); + store + .set("GH_TOKEN", "ghp_abc123", SecretCategory::Tool) + .expect("set tool"); + + let env_vars = store.tool_env_vars(); + assert_eq!(env_vars.len(), 1); + assert_eq!(env_vars.get("GH_TOKEN").unwrap(), "ghp_abc123"); + assert!(!env_vars.contains_key("ANTHROPIC_API_KEY")); + } + + #[test] + fn tool_secret_names_returns_only_tool_names() { + let (store, _file) = temp_store(); + store + .set("OPENAI_API_KEY", "sk-xxx", SecretCategory::System) + .expect("set system"); + store + .set("NPM_TOKEN", "npm_xxx", SecretCategory::Tool) + .expect("set tool"); + store + .set("GH_TOKEN", "ghp_xxx", SecretCategory::Tool) + .expect("set tool"); + + let names = store.tool_secret_names(); + assert_eq!(names.len(), 2); + assert!(names.contains(&"NPM_TOKEN".to_string())); + assert!(names.contains(&"GH_TOKEN".to_string())); + } + + #[test] + fn encryption_lifecycle() { + let (store, _file) = temp_store(); + + // Store a secret in unencrypted mode. + store + .set("MY_SECRET", "plaintext_value", SecretCategory::System) + .expect("set"); + + // Enable encryption. + let master_key = store.enable_encryption().expect("encrypt"); + assert_eq!(store.state(), StoreState::Unlocked); + + // Secret is still readable. + let secret = store.get("MY_SECRET").expect("get after encrypt"); + assert_eq!(secret.expose(), "plaintext_value"); + + // Lock the store. + store.lock().expect("lock"); + assert_eq!(store.state(), StoreState::Locked); + assert!(store.get("MY_SECRET").is_err()); + + // Unlock with correct key. + store.unlock(&master_key).expect("unlock"); + assert_eq!(store.state(), StoreState::Unlocked); + let secret = store.get("MY_SECRET").expect("get after unlock"); + assert_eq!(secret.expose(), "plaintext_value"); + + // Wrong key fails. + store.lock().expect("lock again"); + assert!(store.unlock(b"wrong_key").is_err()); + } + + #[test] + fn key_rotation() { + let (store, _file) = temp_store(); + store + .set("MY_SECRET", "value123", SecretCategory::Tool) + .expect("set"); + let _old_key = store.enable_encryption().expect("encrypt"); + + // Rotate. + let new_key = store.rotate_key().expect("rotate"); + + // Secret still readable with new cipher. + let secret = store.get("MY_SECRET").expect("get after rotate"); + assert_eq!(secret.expose(), "value123"); + + // Lock and unlock with new key. + store.lock().expect("lock"); + store.unlock(&new_key).expect("unlock with new key"); + let secret = store.get("MY_SECRET").expect("get after re-unlock"); + assert_eq!(secret.expose(), "value123"); + } + + #[test] + fn auto_categorize_known_patterns() { + // Tool secrets — anything not recognized defaults to Tool. + assert_eq!(auto_categorize("GH_TOKEN"), SecretCategory::Tool); + assert_eq!(auto_categorize("GITHUB_TOKEN"), SecretCategory::Tool); + assert_eq!(auto_categorize("NPM_TOKEN"), SecretCategory::Tool); + assert_eq!(auto_categorize("AWS_ACCESS_KEY_ID"), SecretCategory::Tool); + assert_eq!(auto_categorize("AWS_SESSION_TOKEN"), SecretCategory::Tool); + assert_eq!(auto_categorize("UNKNOWN_CREDENTIAL"), SecretCategory::Tool); + assert_eq!(auto_categorize("DOCKER_TOKEN"), SecretCategory::Tool); + assert_eq!( + auto_categorize("CARGO_REGISTRY_TOKEN"), + SecretCategory::Tool + ); + + // Non-adapter system secrets (LLM provider keys): + assert_eq!(auto_categorize("ANTHROPIC_API_KEY"), SecretCategory::System); + assert_eq!(auto_categorize("OPENAI_API_KEY"), SecretCategory::System); + assert_eq!( + auto_categorize("OPENROUTER_API_KEY"), + SecretCategory::System + ); + assert_eq!(auto_categorize("GEMINI_API_KEY"), SecretCategory::System); + assert_eq!(auto_categorize("DEEPSEEK_API_KEY"), SecretCategory::System); + assert_eq!(auto_categorize("CEREBRAS_API_KEY"), SecretCategory::System); + + // Messaging adapter tokens (default adapters via AdapterSecrets): + assert_eq!(auto_categorize("DISCORD_BOT_TOKEN"), SecretCategory::System); + assert_eq!(auto_categorize("SLACK_BOT_TOKEN"), SecretCategory::System); + assert_eq!(auto_categorize("SLACK_APP_TOKEN"), SecretCategory::System); + assert_eq!( + auto_categorize("TELEGRAM_BOT_TOKEN"), + SecretCategory::System + ); + assert_eq!( + auto_categorize("TWITCH_OAUTH_TOKEN"), + SecretCategory::System + ); + assert_eq!( + auto_categorize("TWITCH_CLIENT_SECRET"), + SecretCategory::System + ); + assert_eq!( + auto_categorize("EMAIL_IMAP_PASSWORD"), + SecretCategory::System + ); + assert_eq!( + auto_categorize("EMAIL_SMTP_USERNAME"), + SecretCategory::System + ); + + // Named instance patterns — {PREFIX}_{INSTANCE}_{SUFFIX} → System: + assert_eq!( + auto_categorize("DISCORD_ALERTS_BOT_TOKEN"), + SecretCategory::System + ); + assert_eq!( + auto_categorize("SLACK_SUPPORT_BOT_TOKEN"), + SecretCategory::System + ); + assert_eq!( + auto_categorize("SLACK_SUPPORT_APP_TOKEN"), + SecretCategory::System + ); + assert_eq!( + auto_categorize("TELEGRAM_NOTIFICATIONS_BOT_TOKEN"), + SecretCategory::System + ); + assert_eq!( + auto_categorize("TWITCH_GAMING_OAUTH_TOKEN"), + SecretCategory::System + ); + assert_eq!( + auto_categorize("TWITCH_GAMING_CLIENT_SECRET"), + SecretCategory::System + ); + assert_eq!( + auto_categorize("EMAIL_SUPPORT_IMAP_PASSWORD"), + SecretCategory::System + ); + assert_eq!( + auto_categorize("EMAIL_BILLING_SMTP_PASSWORD"), + SecretCategory::System + ); + + // Search integrations: + assert_eq!( + auto_categorize("BRAVE_SEARCH_API_KEY"), + SecretCategory::System + ); + + // Case-insensitive matching: + assert_eq!(auto_categorize("anthropic_api_key"), SecretCategory::System); + assert_eq!(auto_categorize("Openai_Api_Key"), SecretCategory::System); + assert_eq!( + auto_categorize("discord_alerts_bot_token"), + SecretCategory::System + ); + } + + #[test] + fn decrypted_secret_redacts_display() { + let secret = DecryptedSecret("super_secret_value".to_string()); + assert_eq!(format!("{secret}"), "***"); + assert_eq!(format!("{secret:?}"), "DecryptedSecret(***)"); + assert_eq!(secret.expose(), "super_secret_value"); + } + + #[test] + fn list_metadata_works_in_all_states() { + let (store, _file) = temp_store(); + store + .set("KEY1", "val1", SecretCategory::System) + .expect("set"); + store + .set("KEY2", "val2", SecretCategory::Tool) + .expect("set"); + + // Unencrypted: metadata readable. + let meta = store.list_metadata().expect("list"); + assert_eq!(meta.len(), 2); + + // Enable encryption. + let master_key = store.enable_encryption().expect("encrypt"); + + // Unlocked: metadata readable. + let meta = store.list_metadata().expect("list"); + assert_eq!(meta.len(), 2); + + // Locked: metadata still readable. + store.lock().expect("lock"); + let meta = store.list_metadata().expect("list"); + assert_eq!(meta.len(), 2); + + // But values are not. + assert!(store.get("KEY1").is_err()); + + // Unlock restores access. + store.unlock(&master_key).expect("unlock"); + assert_eq!(store.get("KEY1").expect("get").expose(), "val1"); + } + + #[test] + fn export_import_roundtrip() { + let (store1, _file1) = temp_store(); + store1 + .set("KEY_A", "value_a", SecretCategory::System) + .expect("set"); + store1 + .set("KEY_B", "value_b", SecretCategory::Tool) + .expect("set"); + + let export = store1.export_all().expect("export"); + assert_eq!(export.entries.len(), 2); + assert_eq!(export.version, 1); + + // Import into a fresh store. + let (store2, _file2) = temp_store(); + let result = store2.import_all(&export, false).expect("import"); + assert_eq!(result.imported, 2); + assert!(result.skipped.is_empty()); + + assert_eq!(store2.get("KEY_A").expect("get").expose(), "value_a"); + assert_eq!(store2.get("KEY_B").expect("get").expose(), "value_b"); + assert_eq!( + store2.get_metadata("KEY_B").expect("meta").category, + SecretCategory::Tool + ); + } + + #[test] + fn import_skips_existing_without_overwrite() { + let (store, _file) = temp_store(); + store + .set("EXISTING", "original", SecretCategory::System) + .expect("set"); + + let export = ExportData { + version: 1, + encrypted: false, + entries: vec![ + ExportEntry { + name: "EXISTING".to_string(), + value: "new_value".to_string(), + category: SecretCategory::Tool, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }, + ExportEntry { + name: "FRESH".to_string(), + value: "fresh_value".to_string(), + category: SecretCategory::Tool, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }, + ], + }; + + let result = store.import_all(&export, false).expect("import"); + assert_eq!(result.imported, 1); + assert_eq!(result.skipped, vec!["EXISTING"]); + + // Original value preserved. + assert_eq!(store.get("EXISTING").expect("get").expose(), "original"); + // New secret imported. + assert_eq!(store.get("FRESH").expect("get").expose(), "fresh_value"); + } + + #[test] + fn import_overwrites_existing_when_requested() { + let (store, _file) = temp_store(); + store + .set("EXISTING", "original", SecretCategory::System) + .expect("set"); + + let export = ExportData { + version: 1, + encrypted: false, + entries: vec![ExportEntry { + name: "EXISTING".to_string(), + value: "overwritten".to_string(), + category: SecretCategory::Tool, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + }], + }; + + let result = store.import_all(&export, true).expect("import"); + assert_eq!(result.imported, 1); + assert!(result.skipped.is_empty()); + + assert_eq!(store.get("EXISTING").expect("get").expose(), "overwritten"); + } + + #[test] + fn set_updates_existing_preserves_created_at() { + let (store, _file) = temp_store(); + store + .set("MY_KEY", "v1", SecretCategory::Tool) + .expect("set"); + let meta1 = store.get_metadata("MY_KEY").expect("meta"); + + // Update value and category. + store + .set("MY_KEY", "v2", SecretCategory::System) + .expect("update"); + let meta2 = store.get_metadata("MY_KEY").expect("meta"); + + assert_eq!(meta2.created_at, meta1.created_at); + assert!(meta2.updated_at >= meta1.updated_at); + assert_eq!(meta2.category, SecretCategory::System); + assert_eq!(store.get("MY_KEY").expect("get").expose(), "v2"); + } + + #[test] + fn secret_field_name_generation() { + // Adapter field with instance pattern. + let field = SecretField { + toml_key: "token", + secret_name: "DISCORD_BOT_TOKEN", + instance_pattern: Some(InstancePattern { + platform_prefix: "DISCORD", + field_suffix: "BOT_TOKEN", + }), + }; + + assert_eq!(field.secret_name, "DISCORD_BOT_TOKEN"); + assert_eq!( + field.instance_name("alerts"), + Some("DISCORD_ALERTS_BOT_TOKEN".to_string()) + ); + assert_eq!( + field.instance_name("my-bot"), + Some("DISCORD_MY-BOT_BOT_TOKEN".to_string()) + ); + + // Multi-field adapter (Slack). + let bot = SecretField { + toml_key: "bot_token", + secret_name: "SLACK_BOT_TOKEN", + instance_pattern: Some(InstancePattern { + platform_prefix: "SLACK", + field_suffix: "BOT_TOKEN", + }), + }; + let app = SecretField { + toml_key: "app_token", + secret_name: "SLACK_APP_TOKEN", + instance_pattern: Some(InstancePattern { + platform_prefix: "SLACK", + field_suffix: "APP_TOKEN", + }), + }; + assert_eq!(bot.secret_name, "SLACK_BOT_TOKEN"); + assert_eq!( + bot.instance_name("support"), + Some("SLACK_SUPPORT_BOT_TOKEN".to_string()) + ); + assert_eq!(app.secret_name, "SLACK_APP_TOKEN"); + assert_eq!( + app.instance_name("support"), + Some("SLACK_SUPPORT_APP_TOKEN".to_string()) + ); + + // Non-adapter field (LLM key) — no instance pattern. + let llm_field = SecretField { + toml_key: "anthropic_key", + secret_name: "ANTHROPIC_API_KEY", + instance_pattern: None, + }; + assert_eq!(llm_field.secret_name, "ANTHROPIC_API_KEY"); + assert_eq!(llm_field.instance_name("anything"), None); + } + + #[test] + fn system_secret_registry_contains_all_sections() { + let fields = system_secret_registry(); + + // Helper: check if a secret name is in the registry. + let has_secret = |name: &str| fields.iter().any(|f| f.secret_name == name); + + // LLM provider keys. + assert!(has_secret("ANTHROPIC_API_KEY"), "missing ANTHROPIC_API_KEY"); + assert!(has_secret("OPENAI_API_KEY"), "missing OPENAI_API_KEY"); + assert!(has_secret("DEEPSEEK_API_KEY"), "missing DEEPSEEK_API_KEY"); + + // Search / internal tool keys. + assert!( + has_secret("BRAVE_SEARCH_API_KEY"), + "missing BRAVE_SEARCH_API_KEY" + ); + + // Messaging adapter defaults. + assert!(has_secret("DISCORD_BOT_TOKEN"), "missing DISCORD_BOT_TOKEN"); + assert!(has_secret("SLACK_BOT_TOKEN"), "missing SLACK_BOT_TOKEN"); + assert!(has_secret("SLACK_APP_TOKEN"), "missing SLACK_APP_TOKEN"); + assert!( + has_secret("TELEGRAM_BOT_TOKEN"), + "missing TELEGRAM_BOT_TOKEN" + ); + assert!( + has_secret("TWITCH_OAUTH_TOKEN"), + "missing TWITCH_OAUTH_TOKEN" + ); + assert!( + has_secret("EMAIL_IMAP_PASSWORD"), + "missing EMAIL_IMAP_PASSWORD" + ); + + // Adapter fields have instance patterns, LLM fields don't. + let discord_field = fields + .iter() + .find(|f| f.secret_name == "DISCORD_BOT_TOKEN") + .expect("Discord field"); + assert!(discord_field.instance_pattern.is_some()); + + let anthropic_field = fields + .iter() + .find(|f| f.secret_name == "ANTHROPIC_API_KEY") + .expect("Anthropic field"); + assert!(anthropic_field.instance_pattern.is_none()); + } } diff --git a/src/tools.rs b/src/tools.rs index ef6061529..efc37294a 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -40,6 +40,7 @@ pub mod react; pub mod read_skill; pub mod reply; pub mod route; +pub mod secret_set; pub mod send_agent_message; pub mod send_file; pub mod send_message_to_another_channel; @@ -80,6 +81,7 @@ pub use react::{ReactArgs, ReactError, ReactOutput, ReactTool}; pub use read_skill::{ReadSkillArgs, ReadSkillError, ReadSkillOutput, ReadSkillTool}; pub use reply::{RepliedFlag, ReplyArgs, ReplyError, ReplyOutput, ReplyTool, new_replied_flag}; pub use route::{RouteArgs, RouteError, RouteOutput, RouteTool}; +pub use secret_set::{SecretSetArgs, SecretSetError, SecretSetOutput, SecretSetTool}; pub use send_agent_message::{ SendAgentMessageArgs, SendAgentMessageError, SendAgentMessageOutput, SendAgentMessageTool, }; @@ -408,10 +410,18 @@ pub fn create_worker_tool_server( agent_id.clone(), worker_id, )) - .tool(SetStatusTool::new( - agent_id, worker_id, channel_id, event_tx, - )) - .tool(ReadSkillTool::new(runtime_config)); + .tool({ + let mut status_tool = SetStatusTool::new(agent_id, worker_id, channel_id, event_tx); + if let Some(store) = runtime_config.secrets.load().as_ref() { + status_tool = status_tool.with_tool_secrets(store.tool_secret_pairs()); + } + status_tool + }) + .tool(ReadSkillTool::new(runtime_config.clone())); + + if let Some(store) = runtime_config.secrets.load().as_ref() { + server = server.tool(SecretSetTool::new(store.clone())); + } if browser_config.enabled { server = server.tool(BrowserTool::new(browser_config, screenshot_dir)); diff --git a/src/tools/secret_set.rs b/src/tools/secret_set.rs new file mode 100644 index 000000000..4847bce68 --- /dev/null +++ b/src/tools/secret_set.rs @@ -0,0 +1,135 @@ +//! Tool for workers to store secrets in the instance-level secret store. +//! +//! Useful for autonomous workflows where a worker creates accounts, generates +//! API keys, or obtains credentials that should be persisted for future use. + +use crate::secrets::store::{SecretCategory, SecretsStore, auto_categorize}; +use rig::completion::ToolDefinition; +use rig::tool::Tool; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +/// Tool for storing secrets from worker subprocesses. +#[derive(Debug, Clone)] +pub struct SecretSetTool { + secrets_store: Arc, +} + +impl SecretSetTool { + /// Create a new secret set tool with access to the instance-level store. + pub fn new(secrets_store: Arc) -> Self { + Self { secrets_store } + } +} + +/// Error type for secret set tool. +#[derive(Debug, thiserror::Error)] +#[error("Failed to set secret: {0}")] +pub struct SecretSetError(String); + +/// Arguments for secret set tool. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SecretSetArgs { + /// The name of the secret (e.g. "GH_TOKEN", "STRIPE_API_KEY"). + /// Use UPPER_SNAKE_CASE by convention. + pub name: String, + /// The secret value to store. + pub value: String, + /// Optional category override: "system" or "tool". + /// If omitted, the category is auto-assigned based on the name. + /// Most worker-created secrets should be "tool" (exposed to subprocesses). + pub category: Option, +} + +/// Output from secret set tool. +#[derive(Debug, Serialize)] +pub struct SecretSetOutput { + /// Whether the secret was stored successfully. + pub success: bool, + /// The name of the secret that was stored. + pub name: String, + /// The category that was assigned. + pub category: String, + /// Whether this was an update to an existing secret. + pub updated: bool, +} + +impl Tool for SecretSetTool { + const NAME: &'static str = "secret_set"; + + type Error = SecretSetError; + type Args = SecretSetArgs; + type Output = SecretSetOutput; + + async fn definition(&self, _prompt: String) -> ToolDefinition { + ToolDefinition { + name: Self::NAME.to_string(), + description: crate::prompts::text::get("tools/secret_set").to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Secret name in UPPER_SNAKE_CASE (e.g. GH_TOKEN, STRIPE_API_KEY)" + }, + "value": { + "type": "string", + "description": "The secret value to store" + }, + "category": { + "type": "string", + "enum": ["system", "tool"], + "description": "Optional category override. Defaults to auto-categorization based on the name. 'tool' secrets are exposed as env vars in future worker subprocesses. 'system' secrets are only accessible internally." + } + }, + "required": ["name", "value"] + }), + } + } + + async fn call(&self, args: Self::Args) -> Result { + let name = args.name.trim().to_uppercase(); + + if name.is_empty() { + return Err(SecretSetError("secret name cannot be empty".to_string())); + } + + if args.value.is_empty() { + return Err(SecretSetError("secret value cannot be empty".to_string())); + } + + // Determine category: explicit override or auto-categorize. + let category = match args.category.as_deref() { + Some("system") => SecretCategory::System, + Some("tool") => SecretCategory::Tool, + Some(other) => { + return Err(SecretSetError(format!( + "invalid category '{other}' — must be 'system' or 'tool'" + ))); + } + None => auto_categorize(&name), + }; + + // Check if this is an update to an existing secret. + let updated = self.secrets_store.get_metadata(&name).is_ok(); + + self.secrets_store + .set(&name, &args.value, category) + .map_err(|error| SecretSetError(format!("{error}")))?; + + tracing::info!( + name = %name, + category = %category, + updated, + "worker stored secret via secret_set tool" + ); + + Ok(SecretSetOutput { + success: true, + name, + category: category.to_string(), + updated, + }) + } +} diff --git a/src/tools/set_status.rs b/src/tools/set_status.rs index 39f66f1db..2b50ed9c6 100644 --- a/src/tools/set_status.rs +++ b/src/tools/set_status.rs @@ -14,6 +14,8 @@ pub struct SetStatusTool { worker_id: WorkerId, channel_id: Option, event_tx: broadcast::Sender, + /// Tool secret pairs for scrubbing status text before it reaches the channel. + tool_secret_pairs: Vec<(String, String)>, } impl SetStatusTool { @@ -29,8 +31,15 @@ impl SetStatusTool { worker_id, channel_id, event_tx, + tool_secret_pairs: Vec::new(), } } + + /// Set tool secret pairs for output scrubbing. + pub fn with_tool_secrets(mut self, pairs: Vec<(String, String)>) -> Self { + self.tool_secret_pairs = pairs; + self + } } /// Error type for set status tool. @@ -91,6 +100,9 @@ impl Tool for SetStatusTool { args.status }; + // Scrub tool secret values before the status reaches the channel. + let status = crate::secrets::scrub::scrub_secrets(&status, &self.tool_secret_pairs); + let event = ProcessEvent::WorkerStatus { agent_id: self.agent_id.clone(), worker_id: self.worker_id, diff --git a/tests/bulletin.rs b/tests/bulletin.rs index f773c9c76..4f90615eb 100644 --- a/tests/bulletin.rs +++ b/tests/bulletin.rs @@ -9,9 +9,31 @@ use anyhow::Context as _; use std::sync::Arc; +/// Set up the secrets store thread-local so `secret:` references in config.toml +/// resolve correctly. Mirrors the bootstrap logic in main.rs. +fn bootstrap_secrets_for_config() { + let instance_dir = spacebot::config::Config::default_instance_dir(); + let secrets_path = instance_dir.join("data").join("secrets.redb"); + if !secrets_path.exists() { + return; + } + if let Ok(store) = spacebot::secrets::store::SecretsStore::new(&secrets_path) { + let store = Arc::new(store); + // Auto-unlock via OS keystore if encrypted. + if store.is_encrypted() { + let keystore = spacebot::secrets::keystore::platform_keystore(); + if let Some(key) = keystore.load_key("instance").ok().flatten() { + let _ = store.unlock(&key); + } + } + spacebot::config::set_resolve_secrets_store(store); + } +} + /// Bootstrap an AgentDeps from the real ~/.spacebot config, using the first /// (default) agent's databases and config. async fn bootstrap_deps() -> anyhow::Result { + bootstrap_secrets_for_config(); let config = spacebot::config::Config::load().context("failed to load ~/.spacebot/config.toml")?; diff --git a/tests/context_dump.rs b/tests/context_dump.rs index 9b9aad3eb..615a3c447 100644 --- a/tests/context_dump.rs +++ b/tests/context_dump.rs @@ -10,8 +10,29 @@ use anyhow::Context as _; use std::sync::Arc; +/// Set up the secrets store thread-local so `secret:` references in config.toml +/// resolve correctly. Mirrors the bootstrap logic in main.rs. +fn bootstrap_secrets_for_config() { + let instance_dir = spacebot::config::Config::default_instance_dir(); + let secrets_path = instance_dir.join("data").join("secrets.redb"); + if !secrets_path.exists() { + return; + } + if let Ok(store) = spacebot::secrets::store::SecretsStore::new(&secrets_path) { + let store = Arc::new(store); + if store.is_encrypted() { + let keystore = spacebot::secrets::keystore::platform_keystore(); + if let Some(key) = keystore.load_key("instance").ok().flatten() { + let _ = store.unlock(&key); + } + } + spacebot::config::set_resolve_secrets_store(store); + } +} + /// Bootstrap AgentDeps from the real ~/.spacebot config (same as bulletin test). async fn bootstrap_deps() -> anyhow::Result<(spacebot::AgentDeps, spacebot::config::Config)> { + bootstrap_secrets_for_config(); let config = spacebot::config::Config::load().context("failed to load ~/.spacebot/config.toml")?; @@ -334,6 +355,7 @@ async fn dump_worker_context() { false, Vec::new(), Vec::new(), + &[], ) .expect("failed to render worker prompt"); print_section("WORKER SYSTEM PROMPT", &worker_prompt); @@ -506,6 +528,7 @@ async fn dump_all_contexts() { false, Vec::new(), Vec::new(), + &[], ) .expect("failed to render worker prompt"); let browser_config = (**rc.browser_config.load()).clone(); From 5d2b25cf9d56218eff6543926981a1f963924dd2 Mon Sep 17 00:00:00 2001 From: James Pine Date: Sat, 28 Feb 2026 15:44:41 -0800 Subject: [PATCH 02/11] visual tweak + channel prompt reorder --- interface/src/routes/Settings.tsx | 2 +- prompts/en/channel.md.j2 | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index b590ec851..2671c9f0f 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -1251,7 +1251,7 @@ function SecretsSection() {
{secret.name} diff --git a/prompts/en/channel.md.j2 b/prompts/en/channel.md.j2 index 9d7aa8fae..2c72ee571 100644 --- a/prompts/en/channel.md.j2 +++ b/prompts/en/channel.md.j2 @@ -45,13 +45,13 @@ You have three paths for getting things done. Choosing the right one matters. **Branch** — for thinking and memory. Branch when you need to recall, save, or forget something from long-term memory, manage the task board (create, list, update, or approve tasks), reason through a complex decision, figure out what instructions to give a worker, or retrieve transcript context from another channel. Branches have your full conversation context and access to the memory system (recall, save, and delete), task tools (`task_create`, `task_list`, `task_update`), cross-channel transcript recall (`channel_recall`), and worker transcript inspection (`worker_inspect`). They return a conclusion. You never see the working. Branch often — it's cheap and keeps you responsive. -Use `worker_inspect` in a branch when you need to verify what a worker actually did — what tools it called, what results it got, what sources it checked. Useful when a worker returns a thin or unexpected result, or when the user asks "what did you actually do?" - **Worker** — for doing. Workers have execution tools (see Worker Capabilities section below). They do NOT have your conversation context or access to memories — they only know what you tell them in the task description, so be specific. Two flavors: - _Fire-and-forget_ — bounded tasks with a clear end state. "Run the test suite." "Read src/config.rs and summarize it." The worker does it and reports back. - _Interactive_ — open-ended work the user might steer. "Refactor the auth module." "Debug the CI pipeline." The worker stays alive and you route follow-up messages to it when the user gives additional instructions. +Use `worker_inspect` in a branch when you need to verify what a worker actually did — what tools it called, what results it got, what sources it checked. Useful when a worker returns a thin or unexpected result, or when the user asks "what did you actually do?" + **Reply** — for talking. Use reply to respond to the user. This is your primary output. If you can answer directly without thinking or doing, just reply. **React** — for lightweight acknowledgment. Use `react` to add an emoji reaction to the user's message. A reaction can stand on its own (react + skip), accompany a reply (react + reply), or signal you're paying attention without interrupting. Don't overuse it — a well-placed 👀 or 😂 lands better than reacting to everything, but feel free to be creative with your choice of reaction. From b73c822d40883f379447057c9d43b6bf1a8d57c7 Mon Sep 17 00:00:00 2001 From: James Pine Date: Sat, 28 Feb 2026 16:34:19 -0800 Subject: [PATCH 03/11] fix button --- interface/src/routes/Settings.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 2671c9f0f..e6e1fb7dc 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -1123,8 +1123,8 @@ function SecretsSection() { {/* Encryption banner (unencrypted stores) */} {state === "unencrypted" && !storeStatus?.platform_managed && (
-
-
+
+

Encryption not enabled

Secrets are stored without encryption. Enable encryption for protection @@ -1134,9 +1134,9 @@ function SecretsSection() {

From 688a93d0888f523cef72b376a06f86c433a70211 Mon Sep 17 00:00:00 2001 From: James Pine Date: Sat, 28 Feb 2026 19:43:33 -0800 Subject: [PATCH 04/11] fix: process-wide secret resolver and keyctl syscall correctness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback from @vsumner: 1. Replace thread-local RESOLVE_SECRETS_STORE with a process-wide LazyLock> so secret: config references resolve correctly on any thread (file watcher, API handlers, tokio workers). The thread-local only worked on the main thread at startup, causing silent None resolution during hot-reload. 2. Fix wrong keyctl constant in store_key: 0x0b is KEYCTL_READ, not KEYCTL_GET_KEYRING_ID (0x00). The wrong operation returned payload size instead of the keyring serial, which was then passed as a keyring ID to add_key. 3. Fix missing NUL termination on description strings passed to kernel syscalls (add_key, keyctl SEARCH). Rust String does not include a trailing NUL — use CString to guarantee kernel-safe C strings. --- src/config.rs | 44 ++++++++++++++++------------------------- src/prompts/engine.rs | 8 ++------ src/sandbox.rs | 20 +++++++++++++++---- src/secrets/keystore.rs | 20 +++++++++++++++---- 4 files changed, 51 insertions(+), 41 deletions(-) diff --git a/src/config.rs b/src/config.rs index c5c3b3218..e818b7ec6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3292,15 +3292,14 @@ struct TomlBinding { /// - Anything else — literal value. fn resolve_env_value(value: &str) -> Option { if let Some(alias) = value.strip_prefix("secret:") { - // Try the thread-local secrets store if set. - RESOLVE_SECRETS_STORE.with(|cell| { - cell.borrow().as_ref().and_then(|store| { - store - .get(alias) - .ok() - .map(|secret| secret.expose().to_string()) - }) - }) + let guard = RESOLVE_SECRETS_STORE.load(); + match (*guard).as_ref() { + Some(store) => store + .get(alias) + .ok() + .map(|secret| secret.expose().to_string()), + None => None, + } } else if let Some(var_name) = value.strip_prefix("env:") { std::env::var(var_name).ok() } else { @@ -3308,26 +3307,17 @@ fn resolve_env_value(value: &str) -> Option { } } -// Thread-local reference to the secrets store for use during config resolution. -// -// Set before calling config resolution functions and cleared after. This avoids -// threading the secrets store through 60+ `resolve_env_value` call sites. -std::thread_local! { - static RESOLVE_SECRETS_STORE: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; -} +/// Process-wide reference to the secrets store for use during config resolution. +/// +/// Uses `ArcSwap` so it is accessible from any thread (file watcher, API +/// handlers, tokio workers) without the thread-affinity issues of a thread-local. +static RESOLVE_SECRETS_STORE: std::sync::LazyLock< + arc_swap::ArcSwap>>, +> = std::sync::LazyLock::new(|| arc_swap::ArcSwap::from_pointee(None)); -/// Set the secrets store for config resolution on the current thread. +/// Set the secrets store for config resolution (process-wide, any thread). pub fn set_resolve_secrets_store(store: std::sync::Arc) { - RESOLVE_SECRETS_STORE.with(|cell| { - *cell.borrow_mut() = Some(store); - }); -} - -/// Clear the secrets store from the current thread. -pub fn clear_resolve_secrets_store() { - RESOLVE_SECRETS_STORE.with(|cell| { - *cell.borrow_mut() = None; - }); + RESOLVE_SECRETS_STORE.store(std::sync::Arc::new(Some(store))); } fn normalize_timezone(value: &str) -> Option { diff --git a/src/prompts/engine.rs b/src/prompts/engine.rs index 6fa73a3f3..d24c677bf 100644 --- a/src/prompts/engine.rs +++ b/src/prompts/engine.rs @@ -1,6 +1,6 @@ use crate::error::Result; use anyhow::Context; -use minijinja::{context, Environment, Value}; +use minijinja::{Environment, Value, context}; use serde::Serialize; use std::collections::HashMap; use std::sync::Arc; @@ -481,11 +481,7 @@ impl PromptEngine { match self.render_static(template_name) { Ok(value) => { let value = value.trim().to_string(); - if value.is_empty() { - None - } else { - Some(value) - } + if value.is_empty() { None } else { Some(value) } } Err(error) => { tracing::error!(template_name, %error, "failed to render adapter prompt template"); diff --git a/src/sandbox.rs b/src/sandbox.rs index 207635b29..7893dd198 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -345,7 +345,14 @@ impl Sandbox { let tool_secrets = self.tool_secrets(); if config.mode == SandboxMode::Disabled { - return self.wrap_passthrough(program, args, working_dir, &path_env, &config, &tool_secrets); + return self.wrap_passthrough( + program, + args, + working_dir, + &path_env, + &config, + &tool_secrets, + ); } match self.backend { @@ -366,9 +373,14 @@ impl Sandbox { &config, &tool_secrets, ), - SandboxBackend::None => { - self.wrap_passthrough(program, args, working_dir, &path_env, &config, &tool_secrets) - } + SandboxBackend::None => self.wrap_passthrough( + program, + args, + working_dir, + &path_env, + &config, + &tool_secrets, + ), } } diff --git a/src/secrets/keystore.rs b/src/secrets/keystore.rs index c1ab1058c..0b18ba08f 100644 --- a/src/secrets/keystore.rs +++ b/src/secrets/keystore.rs @@ -101,19 +101,25 @@ impl KeyStore for MacOSKeyStore { } } +#[cfg(target_os = "linux")] +use std::ffi::CString; + #[cfg(target_os = "linux")] pub struct LinuxKeyStore; #[cfg(target_os = "linux")] impl KeyStore for LinuxKeyStore { fn store_key(&self, instance_id: &str, key: &[u8]) -> Result<(), SecretsError> { - let description = format!("{SERVICE_NAME}:{instance_id}"); + let description = + CString::new(format!("{SERVICE_NAME}:{instance_id}")).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("invalid key description: {error}")) + })?; // Get the session keyring. let session_keyring = unsafe { libc::syscall( libc::SYS_keyctl, - 0x0b_i64, // KEYCTL_GET_KEYRING_ID + 0x00_i64, // KEYCTL_GET_KEYRING_ID -3_i64, // KEY_SPEC_SESSION_KEYRING 0_i64, // don't create ) @@ -148,7 +154,10 @@ impl KeyStore for LinuxKeyStore { } fn load_key(&self, instance_id: &str) -> Result>, SecretsError> { - let description = format!("{SERVICE_NAME}:{instance_id}"); + let description = + CString::new(format!("{SERVICE_NAME}:{instance_id}")).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("invalid key description: {error}")) + })?; // Search the session keyring for a "user" type key with our description. let key_id = unsafe { @@ -211,7 +220,10 @@ impl KeyStore for LinuxKeyStore { } fn delete_key(&self, instance_id: &str) -> Result<(), SecretsError> { - let description = format!("{SERVICE_NAME}:{instance_id}"); + let description = + CString::new(format!("{SERVICE_NAME}:{instance_id}")).map_err(|error| { + SecretsError::Other(anyhow::anyhow!("invalid key description: {error}")) + })?; // Find the key first. let key_id = unsafe { From 598249a9d95876da5bd5ab4b88c442bebccb1230 Mon Sep 17 00:00:00 2001 From: James Pine Date: Sat, 28 Feb 2026 19:53:46 -0800 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20error=20logging,=20input=20validation,=20defense-in?= =?UTF-8?q?-depth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/secrets.mdx: soften absolute guarantee wording for secret scrubbing coverage. - config.rs: log secret-store read errors in resolve_env_value instead of silently dropping them with .ok(). Failed secret: references now produce a tracing::warn with the alias and error. - main.rs: log failures for tmpfs key deletion (security concern), keystore persistence, and secret store unlock instead of let _ =. - sandbox.rs: apply is_reserved_env_var guard to tool secret injection in all three sandbox paths (bubblewrap, sandbox-exec, passthrough) for defense-in-depth consistency with passthrough_env handling. - secrets/scrub.rs: sort secret values by descending length before replacement so longer secrets are scrubbed first, preventing partial replacement when one secret value is a prefix of another. - secrets/store.rs: log metadata deserialization failures instead of silently skipping corrupted rows. - tools/secret_set.rs: validate secret names against env-var-safe format (A-Z, 0-9, _ only, starting with a letter) before persisting, since names are injected as subprocess environment variables. --- docs/content/docs/(configuration)/secrets.mdx | 2 +- src/config.rs | 11 +++++++---- src/main.rs | 14 ++++++++++---- src/sandbox.rs | 12 ++++++++++++ src/secrets/scrub.rs | 6 +++++- src/secrets/store.rs | 13 +++++++++++-- src/tools/secret_set.rs | 13 +++++++++++++ 7 files changed, 59 insertions(+), 12 deletions(-) diff --git a/docs/content/docs/(configuration)/secrets.mdx b/docs/content/docs/(configuration)/secrets.mdx index 958e80889..52342560c 100644 --- a/docs/content/docs/(configuration)/secrets.mdx +++ b/docs/content/docs/(configuration)/secrets.mdx @@ -5,7 +5,7 @@ description: Credential storage with categories, config resolution, encryption a # 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 never appear in logs, tool output, or LLM context. +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 diff --git a/src/config.rs b/src/config.rs index e818b7ec6..58f0c5458 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3294,10 +3294,13 @@ fn resolve_env_value(value: &str) -> Option { if let Some(alias) = value.strip_prefix("secret:") { let guard = RESOLVE_SECRETS_STORE.load(); match (*guard).as_ref() { - Some(store) => store - .get(alias) - .ok() - .map(|secret| secret.expose().to_string()), + Some(store) => match store.get(alias) { + Ok(secret) => Some(secret.expose().to_string()), + Err(error) => { + tracing::warn!(%error, alias, "failed to resolve secret: reference"); + None + } + }, None => None, } } else if let Some(var_name) = value.strip_prefix("env:") { diff --git a/src/main.rs b/src/main.rs index 66c4f11fa..bc3155269 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1163,8 +1163,12 @@ fn bootstrap_secrets_store( let tmpfs_key_path = std::path::Path::new("/run/spacebot/master_key"); let master_key = if tmpfs_key_path.exists() { std::fs::read(tmpfs_key_path).ok().inspect(|key| { - let _ = std::fs::remove_file(tmpfs_key_path); - let _ = keystore.store_key(KEYSTORE_INSTANCE_ID, key); + if let Err(error) = std::fs::remove_file(tmpfs_key_path) { + tracing::warn!(%error, "failed to remove tmpfs master key — key may remain accessible"); + } + if let Err(error) = keystore.store_key(KEYSTORE_INSTANCE_ID, key) { + tracing::warn!(%error, "failed to persist master key to OS credential store"); + } }) } else { // Try instance-level key first, then fall back to legacy agent keys. @@ -1175,8 +1179,10 @@ fn bootstrap_secrets_store( .or_else(|| load_legacy_keystore_key(&instance_dir)) }; - if let Some(key) = master_key { - let _ = store.unlock(&key); + if let Some(key) = master_key + && let Err(error) = store.unlock(&key) + { + tracing::warn!(%error, "failed to unlock secret store — secrets will be inaccessible"); } } diff --git a/src/sandbox.rs b/src/sandbox.rs index 7893dd198..7989aabfb 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -480,6 +480,10 @@ impl Sandbox { // Only tool-category secrets are injected; system secrets (LLM API keys, // messaging tokens) never enter subprocess environments. for (name, value) in tool_secrets { + if is_reserved_env_var(name) { + tracing::debug!(%name, "skipping reserved tool secret name"); + continue; + } cmd.arg("--setenv").arg(name).arg(value); } @@ -550,6 +554,10 @@ impl Sandbox { } // Inject tool secrets from the secret store. for (name, value) in tool_secrets { + if is_reserved_env_var(name) { + tracing::debug!(%name, "skipping reserved tool secret name"); + continue; + } cmd.env(name, value); } for var_name in &config.passthrough_env { @@ -597,6 +605,10 @@ impl Sandbox { } // Inject tool secrets from the secret store. for (name, value) in tool_secrets { + if is_reserved_env_var(name) { + tracing::debug!(%name, "skipping reserved tool secret name"); + continue; + } cmd.env(name, value); } for var_name in &config.passthrough_env { diff --git a/src/secrets/scrub.rs b/src/secrets/scrub.rs index e601cdf28..0df22e4f5 100644 --- a/src/secrets/scrub.rs +++ b/src/secrets/scrub.rs @@ -199,8 +199,12 @@ pub fn scrub_secrets(text: &str, tool_secrets: &[(String, String)]) -> String { if tool_secrets.is_empty() { return text.to_string(); } + // Sort by descending value length so longer secrets are replaced first. + // This prevents partial replacement when one secret value is a prefix of another. + let mut sorted: Vec<&(String, String)> = tool_secrets.iter().collect(); + sorted.sort_by(|a, b| b.1.len().cmp(&a.1.len())); let mut result = text.to_string(); - for (name, value) in tool_secrets { + for (name, value) in sorted { if !value.is_empty() { result = result.replace(value.as_str(), &format!("[REDACTED:{name}]")); } diff --git a/src/secrets/store.rs b/src/secrets/store.rs index 0039f6e63..6374c32f2 100644 --- a/src/secrets/store.rs +++ b/src/secrets/store.rs @@ -464,8 +464,17 @@ impl SecretsStore { let (key, value) = entry.map_err(|error| { SecretsError::Other(anyhow::anyhow!("failed to read metadata entry: {error}")) })?; - if let Ok(meta) = serde_json::from_str::(value.value()) { - result.insert(key.value().to_string(), meta); + match serde_json::from_str::(value.value()) { + Ok(meta) => { + result.insert(key.value().to_string(), meta); + } + Err(error) => { + tracing::warn!( + key = key.value(), + %error, + "skipping secret with corrupted metadata" + ); + } } } diff --git a/src/tools/secret_set.rs b/src/tools/secret_set.rs index 4847bce68..cbcb86c00 100644 --- a/src/tools/secret_set.rs +++ b/src/tools/secret_set.rs @@ -95,6 +95,19 @@ impl Tool for SecretSetTool { return Err(SecretSetError("secret name cannot be empty".to_string())); } + // Secret names are injected as environment variables, so they must be + // valid env var identifiers: start with a letter, contain only uppercase + // letters, digits, and underscores. + if !name.bytes().next().is_some_and(|b| b.is_ascii_uppercase()) + || !name + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') + { + return Err(SecretSetError( + "secret name must be a valid env var name (A-Z, 0-9, _ only, starting with a letter)".to_string(), + )); + } + if args.value.is_empty() { return Err(SecretSetError("secret value cannot be empty".to_string())); } From 380cb8175f6b4dfd927a60ea99a6d73c4176e9b7 Mon Sep 17 00:00:00 2001 From: James Pine Date: Sat, 28 Feb 2026 19:56:36 -0800 Subject: [PATCH 06/11] fix: propagate non-ENOKEY errors in keystore delete_key delete_key treated any negative KEYCTL_SEARCH return as 'not found', silently swallowing EACCES/EINVAL errors. Now only ENOKEY returns Ok, matching the error handling pattern in load_key. --- src/secrets/keystore.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/secrets/keystore.rs b/src/secrets/keystore.rs index 0b18ba08f..b2792e069 100644 --- a/src/secrets/keystore.rs +++ b/src/secrets/keystore.rs @@ -237,8 +237,14 @@ impl KeyStore for LinuxKeyStore { ) }; if key_id < 0 { - // Not found — already deleted. - return Ok(()); + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENOKEY) { + // Not found — already deleted. + return Ok(()); + } + return Err(SecretsError::Other(anyhow::anyhow!( + "keyctl search failed: {error}" + ))); } // Invalidate the key. From b64a05037b7191e0a8cbfe16ab60dd0685e22df2 Mon Sep 17 00:00:00 2001 From: James Pine Date: Sat, 28 Feb 2026 20:22:01 -0800 Subject: [PATCH 07/11] fix: mutation serialization, export perms, error handling in secrets store Add Mutex<()> mutation guard to serialize set/lock/rotate_key/enable_encryption and prevent interleaving races that could write plaintext to an encrypted store. Write secret exports with 0600 permissions on Unix. Propagate config table read errors instead of silently defaulting to unencrypted. Log per-secret decryption failures in tool_env_vars() instead of silently skipping. --- src/main.rs | 23 +++++++++++++++++++++-- src/secrets/store.rs | 32 ++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index bc3155269..2ea0bee15 100644 --- a/src/main.rs +++ b/src/main.rs @@ -777,8 +777,27 @@ fn cmd_secrets( let count = body["count"].as_u64().unwrap_or(0); let content = serde_json::to_string_pretty(&body) .context("failed to serialize export data")?; - std::fs::write(&output, content) - .with_context(|| format!("failed to write {}", output.display()))?; + // Write with restrictive permissions — this file contains + // plaintext secrets. + #[cfg(unix)] + { + use std::io::Write as _; + use std::os::unix::fs::OpenOptionsExt as _; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&output) + .with_context(|| format!("failed to create {}", output.display()))?; + file.write_all(content.as_bytes()) + .with_context(|| format!("failed to write {}", output.display()))?; + } + #[cfg(not(unix))] + { + std::fs::write(&output, content) + .with_context(|| format!("failed to write {}", output.display()))?; + } eprintln!("Exported {count} secrets to {}", output.display()); if let Some(warning) = body["warning"].as_str() { diff --git a/src/secrets/store.rs b/src/secrets/store.rs index 6374c32f2..0a89e0961 100644 --- a/src/secrets/store.rs +++ b/src/secrets/store.rs @@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt::{Debug, Display, Formatter}; use std::path::Path; -use std::sync::RwLock; +use std::sync::{Mutex, RwLock}; /// Table for secret values. Stores either plaintext UTF-8 or nonce+ciphertext /// depending on the store's encryption mode. @@ -146,6 +146,11 @@ pub struct SecretsStore { cipher_state: RwLock>, /// Whether the redb store has encrypted secrets (persisted flag). encrypted: RwLock, + /// Serializes mutating operations (`set`, `lock`, `rotate_key`, + /// `enable_encryption`) so that multi-step read-then-write sequences + /// cannot interleave and corrupt data. These are all cold-path + /// operations, so contention is irrelevant. + mutation_guard: Mutex<()>, } /// Derived cipher key + salt for the encrypted mode. @@ -217,7 +222,12 @@ impl SecretsStore { })?; match table.get(CONFIG_KEY_ENCRYPTED) { Ok(Some(value)) => value.value() == [1], - _ => false, + Ok(None) => false, + Err(error) => { + return Err(SecretsError::Other(anyhow::anyhow!( + "failed to read encryption flag: {error}" + ))); + } } }; @@ -225,6 +235,7 @@ impl SecretsStore { db, cipher_state: RwLock::new(None), encrypted: RwLock::new(encrypted), + mutation_guard: Mutex::new(()), }) } @@ -280,6 +291,7 @@ impl SecretsStore { value: &str, category: SecretCategory, ) -> Result<(), SecretsError> { + let _guard = self.mutation_guard.lock().expect("mutation guard poisoned"); let state = self.state(); if state == StoreState::Locked { return Err(SecretsError::Other(anyhow::anyhow!( @@ -499,10 +511,15 @@ impl SecretsStore { let mut result = HashMap::new(); for (name, meta) in &metadata { - if meta.category == SecretCategory::Tool - && let Ok(secret) = self.get(name) - { - result.insert(name.clone(), secret.expose().to_string()); + if meta.category == SecretCategory::Tool { + match self.get(name) { + Ok(secret) => { + result.insert(name.clone(), secret.expose().to_string()); + } + Err(error) => { + tracing::warn!(secret = %name, %error, "failed to decrypt tool secret — skipping"); + } + } } } @@ -539,6 +556,7 @@ impl SecretsStore { /// Returns the raw master key bytes for the caller to store in the OS /// credential store and display to the user. pub fn enable_encryption(&self) -> Result, SecretsError> { + let _guard = self.mutation_guard.lock().expect("mutation guard poisoned"); if self.is_encrypted() { return Err(SecretsError::Other(anyhow::anyhow!( "encryption is already enabled" @@ -706,6 +724,7 @@ impl SecretsStore { /// Lock the store. Clears the in-memory cipher key. Encrypted secrets become /// inaccessible until `unlock()` is called again. pub fn lock(&self) -> Result<(), SecretsError> { + let _guard = self.mutation_guard.lock().expect("mutation guard poisoned"); if !self.is_encrypted() { return Err(SecretsError::Other(anyhow::anyhow!( "store is not encrypted — nothing to lock" @@ -718,6 +737,7 @@ impl SecretsStore { /// Rotate the master key. Generates a new key, re-encrypts all secrets and /// the sentinel, updates the salt. Returns the new master key bytes. pub fn rotate_key(&self) -> Result, SecretsError> { + let _guard = self.mutation_guard.lock().expect("mutation guard poisoned"); if self.state() != StoreState::Unlocked { return Err(SecretsError::Other(anyhow::anyhow!( "store must be unlocked to rotate key" From 549e130eca845efaf46427981265b42552cd338f Mon Sep 17 00:00:00 2001 From: James Pine Date: Sat, 28 Feb 2026 20:30:03 -0800 Subject: [PATCH 08/11] fix: move macOS-only dep section to end of Cargo.toml The [target.'cfg(target_os = "macos")'.dependencies] section was placed mid-file, causing all subsequent deps (serenity, emojis, tempfile, etc.) to be scoped to macOS only. This broke Linux CI with 602 unresolved crate errors. --- Cargo.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index aa4439734..9c43c1f5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,10 +92,6 @@ daemonize = "0.5" libc = "0.2" ignore = "0.4" -# OS keystore (macOS Keychain for master key storage) -[target.'cfg(target_os = "macos")'.dependencies] -security-framework = "3" - # Discord serenity = { version = "0.12", default-features = false, features = ["client", "gateway", "model", "cache", "chrono", "rustls_backend"] } async-trait = "0.1" @@ -170,6 +166,10 @@ unimplemented = "deny" [dev-dependencies] tokio-test = "0.4" +# OS keystore (macOS Keychain for master key storage) +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3" + [profile.release] lto = "thin" strip = true From c3a9d611d608600080223217fd46112ea1c94943 Mon Sep 17 00:00:00 2001 From: James Pine Date: Sat, 28 Feb 2026 20:34:07 -0800 Subject: [PATCH 09/11] fix: wrap libc::syscall in unsafe block for Rust 2024 edition --- src/secrets/keystore.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/secrets/keystore.rs b/src/secrets/keystore.rs index b2792e069..5aa738111 100644 --- a/src/secrets/keystore.rs +++ b/src/secrets/keystore.rs @@ -292,11 +292,17 @@ impl KeyStore for LinuxKeyStore { pub unsafe fn pre_exec_new_session_keyring() -> std::io::Result<()> { // KEYCTL_JOIN_SESSION_KEYRING with NULL name creates a new anonymous // session keyring for this process. - let result = libc::syscall( - libc::SYS_keyctl, - 0x01_i64, // KEYCTL_JOIN_SESSION_KEYRING - std::ptr::null::(), - ); + // SAFETY: `libc::syscall` is a direct syscall wrapper. We pass valid + // constants and a null pointer (requesting a new anonymous keyring). + // This runs in a pre_exec context where only async-signal-safe ops are + // permitted — `keyctl` is a direct syscall and qualifies. + let result = unsafe { + libc::syscall( + libc::SYS_keyctl, + 0x01_i64, // KEYCTL_JOIN_SESSION_KEYRING + std::ptr::null::(), + ) + }; if result < 0 { return Err(std::io::Error::last_os_error()); } From 24a22353e73fabf6d7ff54bde939cc32314aed66 Mon Sep 17 00:00:00 2001 From: James Pine Date: Sat, 28 Feb 2026 20:42:16 -0800 Subject: [PATCH 10/11] fix: probe keyring support at startup, never fail worker spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test keyctl once at boot. If the syscall is blocked (restrictive seccomp, gVisor, etc.), disable keyring isolation but let workers start normally. Workers always start — keyring isolation is defense-in-depth, not a hard requirement. --- src/main.rs | 5 ++++ src/secrets/keystore.rs | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/main.rs b/src/main.rs index 2ea0bee15..c3b328fc0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1144,6 +1144,11 @@ const KEYSTORE_INSTANCE_ID: &str = "instance"; fn bootstrap_secrets_store( config_path: &Option, ) -> Option> { + // Probe kernel keyring support before any workers spawn. If keyctl is + // blocked (restrictive seccomp, gVisor, etc.), worker keyring isolation + // is disabled but workers still start normally. + spacebot::secrets::keystore::probe_keyring_support(); + let instance_dir = if let Some(path) = config_path { path.parent() .map(|p| p.to_path_buf()) diff --git a/src/secrets/keystore.rs b/src/secrets/keystore.rs index 5aa738111..34c948f93 100644 --- a/src/secrets/keystore.rs +++ b/src/secrets/keystore.rs @@ -12,10 +12,16 @@ //! keyring via `pre_exec`. use crate::error::SecretsError; +use std::sync::atomic::{AtomicBool, Ordering}; /// Service name used for Keychain/keyring identification. const SERVICE_NAME: &str = "sh.spacebot.master-key"; +/// Whether the kernel keyring `keyctl` syscall is available on this system. +/// Set once at startup by `probe_keyring_support()`. When `false`, the +/// `pre_exec_new_session_keyring` hook is a no-op so workers always start. +static KEYRING_ISOLATION_AVAILABLE: AtomicBool = AtomicBool::new(false); + /// Trait for OS-level credential storage. /// /// Implementations must be Send + Sync for use in async contexts. @@ -277,12 +283,54 @@ impl KeyStore for LinuxKeyStore { } } +/// Probe whether the `keyctl` syscall is available on this system. +/// +/// Call once at startup. Tests `KEYCTL_GET_KEYRING_ID` on the session keyring +/// — a read-only, side-effect-free operation. If it succeeds, worker keyring +/// isolation is enabled. If it fails (restrictive seccomp, gVisor, etc.), +/// workers will still start but without keyring isolation. +#[cfg(target_os = "linux")] +pub fn probe_keyring_support() { + let result = unsafe { + libc::syscall( + libc::SYS_keyctl, + 0x00_i64, // KEYCTL_GET_KEYRING_ID + -3_i64, // KEY_SPEC_SESSION_KEYRING + 0_i64, // don't create + ) + }; + if result >= 0 { + KEYRING_ISOLATION_AVAILABLE.store(true, Ordering::Release); + tracing::debug!("kernel keyring available — worker keyring isolation enabled"); + } else { + let error = std::io::Error::last_os_error(); + tracing::warn!( + %error, + "kernel keyring unavailable — worker keyring isolation disabled \ + (keyctl blocked by seccomp or unsupported kernel)" + ); + } +} + +/// No-op on non-Linux — keyring isolation is a Linux-only feature. +#[cfg(not(target_os = "linux"))] +pub fn probe_keyring_support() {} + +/// Whether worker keyring isolation is available (set by `probe_keyring_support`). +pub fn keyring_isolation_available() -> bool { + KEYRING_ISOLATION_AVAILABLE.load(Ordering::Acquire) +} + /// Spawn a worker subprocess with a fresh empty session keyring (Linux only). /// /// Must be called via `Command::pre_exec()` before `spawn()`. The child gets /// a new session keyring and cannot access the parent's keyring (which holds /// the master key). /// +/// Returns `Ok(())` immediately if keyring isolation is unavailable (probed +/// at startup). Workers always start — keyring isolation is defense-in-depth, +/// not a hard requirement. +/// /// # Safety /// /// This function is intended to be called from `pre_exec` which runs between @@ -290,6 +338,10 @@ impl KeyStore for LinuxKeyStore { /// is a direct syscall and is safe in this context. #[cfg(target_os = "linux")] pub unsafe fn pre_exec_new_session_keyring() -> std::io::Result<()> { + if !KEYRING_ISOLATION_AVAILABLE.load(Ordering::Acquire) { + return Ok(()); + } + // KEYCTL_JOIN_SESSION_KEYRING with NULL name creates a new anonymous // session keyring for this process. // SAFETY: `libc::syscall` is a direct syscall wrapper. We pass valid From 1508d7943faaaedd9829afc1cd8be09b3a64ac1f Mon Sep 17 00:00:00 2001 From: James Pine Date: Sat, 28 Feb 2026 20:45:18 -0800 Subject: [PATCH 11/11] fix: use c-string literals for clippy manual_c_str_literals --- src/secrets/keystore.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/secrets/keystore.rs b/src/secrets/keystore.rs index 34c948f93..66f5d0867 100644 --- a/src/secrets/keystore.rs +++ b/src/secrets/keystore.rs @@ -142,7 +142,7 @@ impl KeyStore for LinuxKeyStore { let result = unsafe { libc::syscall( libc::SYS_add_key, - b"user\0".as_ptr(), + c"user".as_ptr(), description.as_ptr(), key.as_ptr(), key.len(), @@ -171,7 +171,7 @@ impl KeyStore for LinuxKeyStore { libc::SYS_keyctl, 0x0a_i64, // KEYCTL_SEARCH -3_i64, // KEY_SPEC_SESSION_KEYRING - b"user\0".as_ptr(), + c"user".as_ptr(), description.as_ptr(), 0_i64, // don't link to a destination keyring ) @@ -237,7 +237,7 @@ impl KeyStore for LinuxKeyStore { libc::SYS_keyctl, 0x0a_i64, // KEYCTL_SEARCH -3_i64, // KEY_SPEC_SESSION_KEYRING - b"user\0".as_ptr(), + c"user".as_ptr(), description.as_ptr(), 0_i64, )