User Secrets Plugin
The user_secrets plugin stores per-user API keys and tokens encrypted at rest and lets other plugins and the shell tool consume them without exposing the value to the LLM. It is the preferred way to hand GitHub PATs, provider API keys, and similar credentials to a multi-user Codumentor deployment.
Overview
Three delivery modes, all active independently of workspace isolation:
- Mode A —
${secret:<name>}placeholder in YAML config. Any consumer plugin (LLM profilesapi_key, MCP server headers, web-search provider keys) can embed${secret:my_token}in its config. The value is resolved from the authenticated user's registry at request time. - Mode B — Shell env binding (default). The user binds a secret to an env var name (e.g.
gh_pat → GITHUB_TOKEN). Atshell_toolinvocation the value is injected into the subprocess environment; the agent transcript never sees it. - Mode C — Agent-process env var (opt-in). A binding can also be flagged
expose_to_agent, in which case the value is written to the agent's ownos.environfor the turn and restored on exit. Needed when in-process Python libraries (boto3, google-auth, etc.) consult env vars directly. Safe only under serialised agent turns.
Requirements
cryptographypackage (already a Codumentor dependency) — used for Fernet-based encryption.config.auth.jwt_secretset — the Fernet key is derived from it via SHA-256 + URL-safe base64. Rotation is supported viaprevious_jwt_secrets.
Configuration
plugins:
- module: codumentor.plugins.user_secrets
class: UserSecretsPlugin
priority: 3
args:
enabled: true
max_secrets_per_user: 50 # default: 50
previous_jwt_secrets: [] # for key-rotation grace period
priority: 3 runs the plugin's onContextReady hook before model_profiles (priority 5) so ${secret:…} placeholders in a profile's api_key are resolvable.
Requires the user_secrets:manage RBAC permission, granted to power_user and admin by default.
How secrets reach consumers
| Consumer | Where placeholder lives | When it resolves |
|---|---|---|
LLM profile api_key | models.profiles.<name>.api_key (YAML) or per-user profile | AIModels.get_chat_model_for_role at each LLM call |
| MCP remote transport headers | plugins.<mcp>.args.servers.<name>.headers | RemoteTransport._get_request_headers() per outbound request |
Web-search provider api_key | plugins.<websearch>.args.providers.<name>.api_key | SearchProvider.get_api_key() per search |
| Shell tool env var | User binding (TUI) | execute_shell at each command — through build_subprocess_env(set_vars=…) |
${VAR} env-var substitution in YAML continues to work as before; the syntax is disambiguated by the colon (${secret:…} vs ${VAR}).
Secret requirements ("Needed by plugins")
Because secrets are stored centrally and referenced by name, a user otherwise has to know the exact name a plugin expects (e.g. redmine_api_key). To close that gap, any plugin can declare the secrets it needs, and this pane surfaces them in a Needed by plugins section — each flagged set or not set, with a one-click Add this secret that pre-fills the create form (name + description) for the unset ones.
The declaration is a manifest field, not a hook: it carries no ordering and adds no dependency edge on user_secrets. The flow is:
- A plugin lists
secret_requirementsin itsui_manifest()(see Declaring required secrets), built fromcodumentor.core.secrets.SecretRequirement. PluginHost.collect_secret_requirements()pulls them from every loaded plugin and dedups by secret name, merging the declaring plugins (mirrorscollect_api_routers).- This plugin's
GET /requirementsendpoint annotates each withis_setagainst the user's stored secrets; the web component and TUI pane render the section.
Two plugins ship declarations today: redmine (its configured api_key_secret) and mcp (every ${secret:...} found in a server's headers/env/args — the case that keeps secret storage centralized, since an MCP server has no plugin-owned UI to collect a key, only a placeholder reference).
The plugin reaches its sibling plugins through an opt-in set_plugin_host(host) that PluginHost.add() calls — so the endpoint can aggregate requirements without threading the host through every call site.
Storage
SQLite at {agent.storage_dir}/user_secrets/secrets.db with two tables:
secrets— per-user(name → Fernet-encrypted value + metadata JSON). Key rotation is supported: secrets encrypted under a previous JWT secret are lazily re-encrypted with the current one on next read.shell_bindings— per-user(binding_name → secret_name, env_name, expose_to_agent). Plain metadata, no encryption. Bindings cascade-delete with their secret and auto-retarget on rename.
The store is a thin wrapper around codumentor.core.secrets.EncryptedKVStore — the same primitive the model_profiles plugin now uses for its API key field.
Runtime wiring
onContextReady (priority 3) onResponsePersist (priority 99)
────────────────────────────── ──────────────────────────────
load user.secrets → SecretsRegistry restore os.environ
load bindings, resolve to: reset _shell_bindings ContextVar
subprocess_env {ENV_NAME: value} reset _user_secrets ContextVar
agent_env {ENV_NAME: value}
publish via ContextVars
set os.environ[name] for agent_env entries
Execution strategies (DirectExecutionStrategy, IsolatedExecutionStrategy) read get_shell_bindings() at each execute_shell call and merge subprocess_env into build_subprocess_env(set_vars=…). The agent's visible tool-call arguments never contain the value.
API
Mounted at /ui/plugins/user_secrets/ (requires user_secrets:manage):
| Method | Path | Purpose |
|---|---|---|
| GET | /capabilities | Plugin limits |
| GET | /secrets | List user secrets (values omitted) |
| GET | /requirements | Secrets that loaded plugins declare they need, each flagged is_set |
| POST | /secrets/{name} | Create or update a secret |
| PUT | /secrets/{name}/rename | Rename (cascades to bindings) |
| DELETE | /secrets/{name} | Delete (cascades bindings) |
| GET | /bindings | List shell bindings |
| POST | /bindings/{name} | Create or update a binding |
| DELETE | /bindings/{name} | Delete a binding |
All CRUD events are emitted through auth.audit.try_log_event.
TUI
Settings → User Secrets pane: a Needed by plugins section (when any plugin declares requirements) at the top, then add/edit/delete secrets (value masked), then add shell bindings that link a secret to an env var. The expose to agent switch enables Mode C for individual bindings. The pane receives the plugin host via set_plugin_host, injected by the settings screen.
Files
| File | Purpose |
|---|---|
core/secrets/crypto.py | derive_fernet_key, FieldEncryptor (rotation-aware) |
core/secrets/store.py | EncryptedKVStore — per-user encrypted K/V |
core/secrets/resolver.py | SecretsRegistry, ShellBindings, ContextVars, ${secret:…} expansion, referenced_names() |
core/secrets/requirements.py | SecretRequirement — declarative per-plugin secret needs |
plugins/plugin_bus.py | PluginHost.collect_secret_requirements() aggregator + set_plugin_host injection |
plugins/user_secrets/plugin.py | Plugin class, hooks, FastAPI router (incl. /requirements) |
ui/plugins/user_secrets/src/index.ts | x-user-secrets-settings web component (incl. "Needed by plugins") |
plugins/user_secrets/bindings_store.py | Shell-bindings SQLite CRUD |
tui/widgets/settings/user_secrets.py | TUI settings pane |
agent/execution_strategy.py | DirectExecutionStrategy injects bindings into subprocess env |
plugins/workspace_isolation/strategies.py | IsolatedExecutionStrategy does the same for bwrap |
Testing
python -m pytest tests/test_core_secrets.py tests/test_secrets_resolver.py \
tests/test_user_secrets_plugin.py tests/test_shell_bindings.py \
tests/test_shell_bindings_agent_exposure.py \
tests/test_secret_requirements.py -v
See also
- Declaring required secrets — for plugin authors: how to surface a needed secret in this pane
- OAuth — sibling plugin for OAuth 2.0 / refresh-token flows (GitHub, Google, …); reuses the same
FieldEncryptorprimitive - Model Profiles — now consumes
FieldEncryptorfrom core - User Secrets — Design & Roadmap
- Security (admin) — crypto model and multi-user caveats