Three Ways to Store Secrets
Comis supports three methods for managing secrets, each with different security trade-offs. You can use any combination of these methods, and you can upgrade from one to another at any time without downtime.| Method | Security | Best For | Setup |
|---|---|---|---|
| Environment variables (.env) | Basic | Getting started, development | Easy |
| SecretRef in config | Better | Referencing env vars from YAML | Easy |
| Encrypted secrets store | Best | Production deployments | Auto-generated on first boot |
Environment Variables (.env)
The simplest approach and the one most people start with. Create a.env file
in your Comis data directory and set variables like OPENAI_API_KEY=sk-....
Comis loads .env automatically on startup. This is the fastest way to get
running, but secrets are stored in plain text on disk — anyone with file
access can read them.
~/.comis/.env
SecretRef in Config
Instead of putting secrets directly in your YAML config, you can reference secrets from three different sources using theSecretRef syntax. This keeps
your config file free of actual secrets, making it safer to share, back up, or
commit to version control.
The simplest form references an environment variable:
~/.comis/config.yaml
| Provider | Syntax | What it does |
|---|---|---|
env | env:provider/KEY_NAME or ${KEY_NAME} | Reads from the SecretManager (env vars or encrypted store) |
file | file:provider/path/to/file | Reads an absolute path on disk (max 1MB, no symlinks to restricted paths). Append #/json/pointer to extract a field from a JSON file. |
exec | exec:provider/id | Invokes a credential helper binary via JSON-RPC (10s default timeout) |
~/.comis/config.yaml
${VARIABLE_NAME} form tells Comis to look up the value from the
SecretManager at startup. The actual secret never appears in the YAML file
itself. The file: and exec: forms keep secrets out of .env files
entirely — they live wherever your secret store keeps them.
Encrypted Secrets Store (Recommended for Production)
Comis includes a built-in encrypted secrets store that uses AES-256-GCM encryption with HKDF-SHA256 key derivation. Secrets are encrypted at rest in a dedicated database file. Even if someone gains access to your server files, they cannot read the secrets without the master key. This is the recommended approach for any deployment where security matters — which includes most production environments. The encrypted store provides:- Encryption at rest — Secrets are never stored in plain text on disk.
Each encryption uses a fresh 32-byte salt and 12-byte IV; the algorithm is
versioned via an HKDF info string (
comis-secrets-v1) so it can be rotated without breaking older ciphertexts. - Key derivation — The master key is never used directly; a derived key is created for each operation using HKDF-SHA256.
- Integrity verification — AES-256-GCM provides authenticated encryption via a 16-byte tag, so tampered data is detected and rejected.
- In-memory cache — Decrypted values live in process memory (defensive copies, not shared references) so repeated reads do not require repeated decryption. The cache is invalidated on rotation and cleared on shutdown.
Encrypted Store (Auto-Generated)
The encrypted secrets store is enabled by default. On first boot, Comis auto-generates a 32-byte master key and writes it to~/.comis/.env as SECRETS_MASTER_KEY=<hex> (file mode 0600, owner-read-only).
Backup obligation: The key in ~/.comis/.env is the only copy. Losing it makes
secrets.db permanently unreadable (AES-256-GCM, no key escrow). Back up this file
to a secure location immediately after first boot.
Opting Out
To run without the encrypted store, set the storage mode inconfig.yaml:
security.storage: file keeps a plaintext-at-0600 store instead.
The daemon emits a startup WARN when a non-encrypted storage mode is active.
End-to-End Walkthrough: Store, Scope, Rotate, Delete
Here is a complete example of managing an OpenAI API key for a single agent namedcustomer-support, from initial storage to rotation to deletion.
Store the secret
Add the key to the encrypted store. The CLI prompts interactively so the
value never appears in your shell history.
Scope it to one agent
Edit your config so only the Reload the config:
customer-support agent can read the key:~/.comis/config.yaml
node packages/cli/dist/cli.js config reload.Verify the scope
Trigger a read from the wrong agent and confirm it is denied. Watch the
audit log:Every access — successful, denied, or not-found — emits a
secret:accessed
audit event tagged with the agent ID.Rotate the key
When the upstream provider issues a new key, write it over the old one.
Comis re-encrypts on write and invalidates its in-memory cache:Existing agent sessions pick up the new value on the next read — no
daemon restart required.
Per-Agent Secret Access
By default, every agent can access all secrets in the store. While this is convenient for getting started, it means a compromised agent could potentially access secrets it does not need. For better security, restrict each agent to only the secrets it needs using glob patterns. This follows the principle of least privilege — each agent only gets the secrets it requires for its specific role.~/.comis/config.yaml
Pattern Matching
Theallow list supports standard glob patterns. Patterns are case-sensitive,
so make sure they match the exact casing of your secret names:
| Pattern | Matches | Does Not Match |
|---|---|---|
openai_* | openai_key, openai_org | OPENAI_KEY (case-sensitive) |
ANTHROPIC_* | ANTHROPIC_API_KEY | anthropic_key |
*_API_KEY | OPENAI_API_KEY, GOOGLE_API_KEY | openai_key |
Auditing Your Secrets
Regular auditing helps you catch configuration drift and ensure your secrets are properly protected. Runcomis doctor to check your secrets
configuration. The audit examines:
- Plaintext secrets in config files — Secrets that should be migrated to
the encrypted store or referenced via SecretRef. A field that already uses a
${VAR}env-substitution reference or a structuredSecretRefis recognized as properly configured and is not flagged. - Known provider API keys in .env files — Provider-specific keys (like OpenAI, Anthropic, Deepgram) that should be in the encrypted store for production
- File permission issues — Config and secrets files that have overly permissive access (readable by other users on the system)
- Missing master key — The encrypted store is enabled but the
SECRETS_MASTER_KEYenvironment variable is not set - Unencrypted backup copies — Backup files that may contain unencrypted secret data
Configuration Reference
All secrets-related settings in your config file:| Setting | Default | Description |
|---|---|---|
security.storage | "encrypted" | Credential storage backend for all stores: encrypted (AES-256-GCM SQLite), file (plaintext at 0600), or env (read-only, .env/process.env). The secrets database is always <data dir>/secrets.db. |
agents.<name>.secrets.allow | [] (unrestricted) | Glob patterns for allowed secret names |
SECRETS_MASTER_KEY environment variable is auto-generated on first boot and
written to ~/.comis/.env (mode 0600). You can provide it explicitly to override
the auto-generated value. This key is never stored in the config file itself.
To disable the encrypted store entirely, set
security.storage: env (or file)
in config.yaml. The daemon then boots in that mode and emits a startup WARN. Use
env only if you need to manage secrets exclusively via .env files.Provider API Keys
When configuring LLM providers via theproviders_manage tool, API keys follow the same SecretManager workflow:
- Store the key:
gateway({ action: "env_set", env_key: "NVIDIA_API_KEY", env_value: "nvapi-..." }) - Reference in provider config:
providers_manage({ action: "create", provider_id: "nvidia", config: { apiKeyName: "NVIDIA_API_KEY", ... } })
apiKeyName), never the key value itself. The actual key is resolved at runtime via SecretManager. For local providers like Ollama that don’t require API keys, omit apiKeyName entirely.
Broker Injection (Network Boundary)
For exec tools that drive API-key CLIs, the credential broker provides a second injection path that keeps the key out of the sandbox entirely. Two injection paths for exec tools:secretRefs (env injection) | Credential broker (network injection) | |
|---|---|---|
| What it does | Injects the secret as an env var inside the exec sandbox | Injects the secret at the network boundary (TLS termination) |
| Secret visibility | Env var is visible inside the sandbox (echo $MY_KEY) | Placeholder only inside sandbox; real key never enters |
| Use when | Simple scripts needing a key as an environment variable | API-key CLIs (Claude Code, curl with auth, Finnhub SDK) |
| Platform | All platforms | Linux broker-only egress is R1-gated; TLS injection works everywhere |
OAuth Credential Storage
OAuth profiles for subscription-based providers (currently OpenAI Codex) are stored separately from the read-onlySecretManager interface documented above. The OAuth credential store is mutable because tokens refresh on a 30-minute cadence and the rotated refresh token must be persisted before the lock holding the in-flight refresh releases.
Two storage backends are supported, selected by security.storage:
| Mode | Path | Hot-reload | Threat posture |
|---|---|---|---|
encrypted (default) | oauth_profiles table inside secrets.db | No — SQLite WAL masks events | AES-256-GCM ciphertext per row |
file | ~/.comis/auth-profiles.json | Yes — chokidar 100ms debounce | Filesystem perms only (0o600) |
File mode (security.storage: file)
- Path —
~/.comis/auth-profiles.json(underCOMIS_DATA_DIRif overridden). - Permissions — mode
0o600is enforced on every write. Files created with weaker perms are rejected at boot. - Atomic write — the daemon writes to a temp file,
fsyncs, then renames into place. Crashes mid-write cannot leave a half-written file visible. When the daemon runs undernode --permission, thefsynccall is skipped (the fd-based fsync API is disabled by the permission model — see Node Permissions reference). Writes remain atomic (tmp -> rename) but are best-effort durability under--permission. - Cross-process locking — multiple daemons or the daemon + a
comis auth loginCLI all refreshing the same profile coordinate viaproper-lockfile. The refresh-then-persist critical section runs under the lock; the lock is released only after the rotated refresh token is on disk. - Hot-reload — a chokidar watcher with 100ms debounce surfaces external writes (e.g.,
comis auth loginfrom a different shell) to the running daemon without restart.
Encrypted mode (security.storage: encrypted)
- Storage — the
oauth_profilestable inside the same SQLite database assecrets.db. - Algorithm — each row stores the full profile JSON as one AES-256-GCM ciphertext blob with per-row 12-byte IV and 16-byte auth tag. The denormalized
expires_atepoch-millisecond column is the only plaintext field, kept for fastcomis doctorscans. - Key derivation — shares the
SecretsCryptoengine with the encrypted secrets store; HKDF-SHA256 derives a fresh per-row key from the master. - Requirement —
SECRETS_MASTER_KEYmust be set; the daemon fails fast on boot otherwise. - Hot-reload limitation — SQLite WAL writes do not surface as
changeevents on the database file; CLI-written profiles require a daemon restart to take effect.
Email semi-redaction in logs
Profile identities are emails (e.g.,user@example.com). Comis logs never include the access or refresh token at any level (Pino auto-redaction is the safety net; the canonical fields list in CLAUDE.md excludes them). Emails are semi-redacted before logging using a first-char + last-char per atom strategy: user@example.com becomes u***@e***.com. This preserves enough identity to debug a multi-account routing issue while denying log readers the full email.
The redaction is performed at the structured-log call site, not by Pino’s redaction list, so it survives even if the log call accidentally interpolates the email into the message string. Verified by tests/log-redaction-canary.test.ts.
Threat model
- T-OAUTH-DISK-EXFIL — Disk-read attacker. An attacker with read access to
~/.comis/could exfiltrate stored access and refresh tokens. Mitigation: Plaintext mode enforces0o600perms (user-only read). Encrypted mode stores tokens as AES-256-GCM ciphertext with per-row IV/auth-tag, defeating raw-byte scans (verified by canary tests). - T-OAUTH-REFRESH-RACE — Concurrent refresh race. Two daemon instances or daemon+CLI refreshing the same token simultaneously could result in
refresh_token_reused(which auto-locks the account at OpenAI). Mitigation: Per-profile cross-process file lock viaproper-lockfilewith 5-retry exponential backoff; refresh persistence happens before lock release, so concurrent callers see the rotated token via the lock-protected critical section. - T-OAUTH-ENV-DRIFT — Env-var drift. An operator updates
OAUTH_OPENAI_CODEXin.envafter a profile is already in the store; the daemon silently uses the stored profile, leading to confusion. Mitigation: WARN-once-per-(provider, process) when the env-var refresh-token differs from the stored profile, withhint: "env-override-ignored"anderrorKind: "config_drift". Operators should runcomis auth logout --profile <id>then restart the daemon to re-bootstrap from env, OR delete the env var.
Output Guard for Secret Egress
writeSecretGuard controls what happens when the daemon detects a secret-shaped value in a file write path (for example, a tool writing a .env.example or a test fixture). This prevents a careless agent from writing a live API key to disk in cleartext.
Configure via security.writeSecretGuard in config.yaml:
~/.comis/config.yaml
| Value | Behavior |
|---|---|
warn (default) | Write proceeds with scrubbed content and a redirect hint logged. Safe for .env.example files and hex SHAs. |
block | Write is rejected when secret-shaped values are detected. Use in high-security environments where no plaintext secrets should ever touch disk. |
off | No write-time secret scan. Use only if you have an external DLP tool or the false-positive rate on your workload is unacceptable. |
packages/core/src/config/schema-security.ts:82 — z.enum(["warn", "block", "off"]).default("warn").optional()
Migrating from Pre-v1.5 Configuration
In v1.5, the unifiedsecurity.storage key replaced three legacy keys that are now removed from the schema. If your config.yaml or .env still contains any of these, the daemon will fail boot immediately with:
oauth.storage(undersecurity:)security.secrets.enabledCOMIS_DISABLE_ENCRYPTED_SECRETS(in.env)
AppConfigSchema uses z.strictObject, any unrecognized key triggers a VALIDATION_ERROR and the daemon refuses to start — there is no silent fallback.
Migration steps
Step 1 — Detect legacy keys:- Delete any
oauth.storage:line undersecurity:inconfig.yaml - Delete any
security.secrets.enabled:line fromconfig.yaml - Delete any
COMIS_DISABLE_ENCRYPTED_SECRETS=line from.env
security.storage: to config.yaml:
~/.comis/config.yaml
encrypted, ensure SECRETS_MASTER_KEY is set in .env. If it is not set, run comis secrets init to generate one.
Step 4 — Verify boot:
WARN if the inactive backend still holds credentials the active mode cannot reach:
"Inactive encrypted secrets store has real credentials"— emitted when switching fromencryptedtofileorenv"Inactive file secret store has real secrets"— emitted when switching fromfiletoencrypted
hint field with manual migration steps. Run comis secrets list after the storage-mode switch, then manually re-import any stranded credentials.
Live Credential Apply
Storing, rotating, or deleting a credential mid-conversation takes effect immediately — no daemon restart required. When you runcomis secrets set <NAME> or comis secrets delete <NAME>, the daemon writes to the credential store and then upserts (or removes) the value in the shared in-memory MutableSecretManager Map in the same RPC call. Both the broker and exec agents read from that Map on every request, so the next tool invocation observes the new value with no restart.
The RPC response carries "restarting": false to confirm:
What applies live vs. what requires a restart
| Credential type | CLI command | Live? | Note |
|---|---|---|---|
| Named secrets | comis secrets set <NAME> | Yes | Applies on the next broker/exec request |
| Named secrets (delete) | comis secrets delete <NAME> | Yes | Removed from Map immediately |
| MCP OAuth tokens | comis mcp login | Yes | Token written to credential store and applies live |
OAuth profiles (file mode) | comis auth login | Yes | chokidar hot-reload picks up the new profile |
OAuth profiles (encrypted mode) | comis auth login | No | SQLite WAL masks file-change events; daemon restart needed |
The encrypted-mode OAuth limitation is a known constraint of using SQLite WAL for storage. For workloads that rotate OAuth profiles frequently, consider
security.storage: file or triggering a graceful daemon restart after comis auth login.packages/daemon/src/api/secrets-handlers.ts:399-414, 579-596; packages/core/src/security/secret-manager.ts:81-129
Related
Defense in Depth
How secrets fit into the security layers
Hardening
Complete security hardening checklist
Secret Manager Reference
Technical reference for the secret manager
Configuration
Config.yaml setup guide
OAuth
Subscription-based authentication for Codex
