Secrets and credentials¶
Bernstein has two distinct secret stores and a small set of provider env vars that connect them. This page is the one place to look when you are asking where does this token live, who can read it, and how does it reach the agent.
If you are instead asking which env vars get filtered out when an agent is spawned, see env-isolation.md. The two docs are siblings: env-isolation is about what is dropped on the way to the subprocess; this page is about what is stored on disk and how it is loaded in the first place.
Overview¶
Bernstein recognises four distinct ways a secret can reach the orchestrator:
- The credential vault — third-party developer credentials (GitHub, Linear, Jira, Slack, Telegram) the user
bernstein connect-ed once. Stored in the OS keychain by default; AES-GCM file blob on headless boxes. This is the canonical place for human-supplied tokens. - External secret managers — HashiCorp Vault, AWS Secrets Manager, or 1Password CLI. Used when the operator already runs a secrets manager and wants Bernstein to read from it at startup (
secrets.py) or inject short-lived credentials at agent spawn time (vault_injector.py). - Provider env vars —
ANTHROPIC_API_KEY,OPENAI_API_KEY,CLOUDFLARE_API_TOKEN,GOOGLE_API_KEY, etc. Read directly from the orchestrator's environment by individual adapters and bridges. This is the path most operators start with, and is still the supported way to feed LLM-provider keys. .envfiles — Bernstein does not auto-load.envfiles. If you keep your provider keys in a.env, your shell or process manager (direnv,systemd,docker compose env_file, etc.) is responsible for sourcing them before launchingbernstein. See .env file conventions below.
The rest of this page covers each store in detail and ends with operator best practices.
Provider env vars¶
This is the table of provider env vars Bernstein reads. Each row is either an LLM/model provider (consumed by adapters and the routing layer) or an integration provider (consumed by a feature like the GitHub App, Datadog APM, or the Cloudflare bridges).
| Provider | Variables | Read in |
|---|---|---|
| Anthropic / Claude | ANTHROPIC_API_KEY | adapters/claude.py, adapters/aider.py, adapters/amp.py |
| OpenAI | OPENAI_API_KEY, OPENAI_ORG_ID, OPENAI_ORGANIZATION, OPENAI_BASE_URL | adapters/codex.py, adapters/qwen.py, adapters/aider.py |
| Azure OpenAI | AZURE_OPENAI_API_KEY | adapters/aider.py |
| Google AI / Gemini | GOOGLE_API_KEY or GEMINI_API_KEY, GOOGLE_CLOUD_PROJECT, GOOGLE_APPLICATION_CREDENTIALS | adapters/gemini.py |
| Cloudflare | CLOUDFLARE_API_TOKEN (alias CF_API_TOKEN), CLOUDFLARE_ACCOUNT_ID (alias CF_ACCOUNT_ID) | bridges/cloudflare*.py, bridges/browser_rendering.py, bridges/r2_sync.py |
| Cody / Sourcegraph | SRC_ACCESS_TOKEN, SRC_ENDPOINT | adapters/amp.py |
| Kiro | KIRO_API_KEY | adapters/kiro.py |
| Kilo | KILO_API_KEY | adapters/kilo.py |
| OpenRouter | OPENROUTER_API_KEY_FREE, OPENROUTER_API_KEY_PAID | core/routing/openrouter.py |
| GitHub (legacy env) | GITHUB_TOKEN | core/security/vault/resolver.py (legacy fallback for vault) |
| Linear (legacy env) | LINEAR_API_KEY | core/security/vault/resolver.py (legacy fallback for vault) |
| Jira (legacy env) | JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN, JIRA_USER_EMAIL, JIRA_ISSUE_KEY | core/security/vault/resolver.py, cli/commands/ticket_cmd.py |
| Slack (legacy env) | BERNSTEIN_SLACK_TOKEN, SLACK_BOT_TOKEN, SLACK_WEBHOOK_URL | core/security/vault/resolver.py, core/notifications/slack.py |
| Telegram (legacy env) | BERNSTEIN_TELEGRAM_TOKEN, TELEGRAM_BOT_TOKEN | core/security/vault/resolver.py, core/notifications/telegram.py |
| GitHub App | GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET | core/git/github_app.py |
| Datadog | DD_API_KEY (alias DATADOG_API_KEY), DD_SITE, DD_SERVICE | core/observability/apm_integration.py |
| PagerDuty | PAGERDUTY_ROUTING_KEY | core/notifications/pagerduty.py |
| Bernstein server auth | BERNSTEIN_AUTH_TOKEN, BERNSTEIN_AUTH_JWT_SECRET | core/security/auth_middleware.py |
| Vault file backend | BERNSTEIN_VAULT_BACKEND, BERNSTEIN_VAULT_PASSPHRASE_ENV (and the env-var it names) | core/security/vault/factory.py |
| HashiCorp Vault server | VAULT_ADDR, VAULT_TOKEN | core/security/secrets.py, core/security/vault_injector.py |
The rule of thumb is: anything *_API_KEY / *_API_TOKEN / *_BOT_TOKEN / *_WEBHOOK_* is a secret; anything *_BASE_URL / *_ACCOUNT_ID / *_PROJECT is configuration that is fine to log.
bernstein doctor prints a diff of which provider env vars are set versus which ones any active feature actually needs.
The bernstein creds group¶
The credential vault is the canonical place for the five third-party providers Bernstein integrates with directly: GitHub, Linear, Jira, Slack, and Telegram. (LLM-provider keys still come from env vars; the vault is for developer-tool credentials, not model API keys.)
The vault has two top-level CLI commands:
bernstein connect <provider>— guided paste or OAuth-device-code flow that validates the credential against the provider's whoami endpoint and stores it. (Lives atcli/commands/creds_cmd.py:95.)bernstein creds <list|revoke|test>— inspect and manage what is already stored. (Lives atcli/commands/creds_cmd.py:214.)
bernstein connect¶
bernstein connect github # paste a personal access token
bernstein connect linear --oauth # OAuth device-code flow
bernstein connect jira # email + base URL + API token
bernstein connect slack # paste a bot token (xoxb-...)
bernstein connect telegram # paste a bot token (123:ABC-DEF...)
Behaviour:
- The command consults
core/security/vault/providers.pyfor the prompt list. Secret fields are read withgetpass.getpass()so the token never echoes; non-secret fields (Jira email, base URL) useclick.prompt. - The supplied secret is sent to the provider's whoami endpoint. On success, the user's account label (e.g. GitHub login, Atlassian email) is recorded. On failure the secret is not stored — you see the masked token and the error.
- The validated secret + metadata is written to the vault, and an audit event of type
vault.connectis appended to.sdd/audit/YYYY-MM-DD.jsonl.
bernstein creds list¶
Prints a table with provider, account, fingerprint, created-at, last-used-at. Never prints the secret itself. The fingerprint is a 12-character SHA-256 prefix used to identify a token without exposing it.
Provider Account Fingerprint Created Last used
github alex-octocat ab12cd34ef56 2026-04-25T12:00:00 2026-05-04T08:14:21
slack bernstein-bot 0099aabbccdd 2026-04-26T10:11:02 never
bernstein creds revoke <provider>¶
Removes the entry from the local vault. For providers that expose a self-service revoke endpoint (currently Slack via auth.revoke), the CLI also calls that endpoint so the upstream token is invalidated. For providers without a programmatic revoke (GitHub PATs, Jira API tokens, Telegram bot tokens), the CLI deletes the local copy and prints a hint pointing to the provider's UI for full rotation.
bernstein creds test <provider>¶
Re-validates a stored credential against the provider's whoami endpoint. Use this after a long pause to confirm a token has not been revoked upstream, or as a smoke test in CI.
Backend selection (--backend)¶
Both connect and the creds subcommands accept:
--backend keyring(default) — store in the OS keychain.--backend file --passphrase-env VAR— store in~/.config/bernstein/vault.enc, encrypted with a passphrase read from$VAR.
The default can also be set via:
BERNSTEIN_VAULT_BACKEND=keyring|fileBERNSTEIN_VAULT_PASSPHRASE_ENV=NAME_OF_ENV_VAR_THAT_HOLDS_THE_PASSPHRASE
so containers can opt into the file backend without plumbing CLI flags through every entry point.
CredentialVault internals¶
Mechanism by backend, source of truth in src/bernstein/core/security/vault/:
Keyring backend (backend_keyring.py)¶
Default. Delegates to the OS keychain via the keyring package:
| OS | Backend |
|---|---|
| macOS | Keychain Services |
| Linux | Secret Service / libsecret (gnome-keyring, KDE Wallet, ...) |
| Windows | Credential Manager via DPAPI |
Each provider gets a separate account string under the service name bernstein. The stored value is a JSON envelope:
{
"secret": "...",
"account": "alex@example.com",
"fingerprint": "ab12cd34ef56",
"created_at": "2026-04-25T12:00:00Z",
"last_used_at": null,
"metadata": {"scope": "repo,issues"}
}
The keychain handles encryption-at-rest; the backend is a thin serialiser. Who can decrypt is whoever holds the OS user session — unlock the keychain, you read the secret. There is no Bernstein-level master key.
A small "provider index" entry (__bernstein_provider_index__) is maintained alongside the per-provider entries because OS keychain APIs do not expose a portable "list all entries for service X" operation.
File backend (backend_file.py)¶
Opt-in fallback for headless boxes (containers, CI, hardened servers without a desktop session). Encrypts a single JSON blob to ~/.config/bernstein/vault.enc (mode 0600).
Crypto details:
- AES-256-GCM via
cryptography.hazmat.primitives.ciphers.aead.AESGCM. - 32-byte key derived via PBKDF2-HMAC-SHA256, 200 000 iterations, 16-byte random salt stored in the file header.
- Fresh 12-byte nonce on every write; concatenated nonce | ciphertext | GCM tag, base64-encoded.
- Atomic temp-file + rename so a crash leaves the previous vault intact instead of producing a half-written file.
The passphrase is always read from an env var named by --passphrase-env (or $BERNSTEIN_VAULT_PASSPHRASE_ENV). The backend refuses to start if that env var is unset or empty — booting with no protection would silently downgrade security versus the keyring backend.
Who can decrypt: anyone who can cat vault.enc and read the passphrase env var. In practice that means whoever runs the Bernstein process. Treat the passphrase env var the same way you would treat the secrets the vault protects.
Audit chain (audit.py)¶
Every connect, read, revoke, and test writes a vault.{action} entry into .sdd/audit/YYYY-MM-DD.jsonl via bernstein.core.security.audit.AuditLog. Entries record provider id, account label, fingerprint, and backend — never the secret material itself. The audit log is HMAC-chained so tampering is detectable.
Audit failures are deliberately non-fatal on read paths: a broken audit setup logs a warning rather than locking the user out of their own credentials. Connect and revoke do raise on audit failure so misconfiguration is visible.
Vault-first resolution (resolver.py)¶
Higher-level commands like bernstein from-ticket, bernstein chat, and bernstein pr call resolve_secret(provider_id, vault=...) rather than reading env vars directly. The resolver tries the vault first; on miss it falls back to the legacy env var (e.g. GITHUB_TOKEN for GitHub) and emits a one-time DeprecationWarning per (provider, env-var) pair. This is the migration path: existing users keep working, but they are nudged toward bernstein connect.
External secret managers (secrets.py / vault_injector.py)¶
For operators who already run a secrets manager, Bernstein has two integration points. They are completely independent of the bernstein creds vault above.
Startup-time loading: core/security/secrets.py¶
Configured via bernstein.yaml:
secrets:
provider: vault # or "aws", "1password"
path: secret/bernstein # provider-specific path/ARN
ttl: 300 # cache for 5 minutes
field_map: # rename secret fields → env-var names
anthropic_key: ANTHROPIC_API_KEY
openai_key: OPENAI_API_KEY
On startup, load_secrets() fetches the named secret, applies field_map, caches the result with the configured TTL, and exposes the values as environment variables for the rest of the orchestrator to consume. A background SecretsRefresher thread refreshes at 80% of the TTL so spawned agents do not stall on a sync re-fetch.
Supported providers:
- HashiCorp Vault — KV v2 API,
VAULT_ADDR+VAULT_TOKEN. - AWS Secrets Manager — boto3, picks up the standard AWS auth chain (env / profile / IAM role).
- 1Password — shells out to the
opCLI (op item get); requires the user to haveop signin-ed.
If the provider is unreachable, Bernstein falls back to env-var values for any names appearing in field_map.values() so a transient outage does not crash the orchestrator.
Spawn-time injection: core/security/vault_injector.py¶
Same three providers, different lifecycle. The injector is for ephemeral, per-agent credentials:
- Vault dynamic secrets: creates a short-lived lease, revokes via
/v1/sys/leases/revokewhen the agent exits. - AWS STS: requests
assume_roleorget_session_tokenwith a short duration; credentials expire automatically. - 1Password: reads a static item; the value is cleared from the returned dict after injection so it cannot be re-read later in the same process.
Use the injector when you want an agent to have a database password or a cloud role for the lifetime of one task and never see it again.
.env file conventions¶
Bernstein does not call dotenv.load_dotenv() itself — there is no implicit .env discovery, no python-dotenv dependency in the runtime, and no --env-file flag on bernstein or bernstein run.
If you want to use a .env file, your launch wrapper has to load it. The conventional patterns:
| Wrapper | How to source .env before bernstein runs |
|---|---|
| Shell + direnv | Drop dotenv into .envrc; direnv allow |
| systemd unit | EnvironmentFile=/etc/bernstein/.env in the unit |
| docker compose | env_file: .env on the bernstein service |
| Kubernetes | envFrom: secretRef: from the chart values |
| GitHub Actions | env: block at job/step level, fed from secrets |
Precedence inside Bernstein, when multiple paths populate the same key:
- Variables explicitly set in the orchestrator's environment win.
- Values written by the secrets-manager loader (
secrets.py) at startup overlay ontoos.environ. - Per-agent injections (
vault_injector.py) overlay on top of the inherited env when the agent subprocess is spawned, but only for the keys named inenv_map. - The credential vault is consulted last via
resolver.py, and only for the five providers it manages — the vault never overrides an already-set legacy env var, only fills in when the env var is missing or empty.
Two practical consequences:
- A
.envchange does not take effect for a running orchestrator; restart Bernstein. - If you have both
GITHUB_TOKENin the environment andbernstein connect githubran, the env var wins and you get the deprecation warning. Runbernstein creds revoke githubto drop the vault entry, or unset the env var to let the vault take over.
Sibling concern: env-var isolation¶
The flow above gets secrets into the orchestrator. A separate, equally important flow gates which secrets reach a spawned agent subprocess:
- This page (
secrets.md): "where do secrets live and how are they loaded into the orchestrator process?" env-isolation.md: "when the orchestrator spawns an agent, which of those env vars actually get passed through?"
Even if the orchestrator has STRIPE_SECRET_KEY and DATABASE_URL loaded, a spawned agent does not see them — build_filtered_env() returns a fresh dict containing only the base allowlist plus a small per-adapter set of provider keys. See env-isolation.md for the allowlist, the per-adapter extras, and the verification recipe.
This split is intentional: it lets the orchestrator be the trusted component that holds secrets, and lets each agent be a much smaller blast radius.
Best practices¶
-
Never commit secrets. A
.gitignoreentry for.envandvault.encplus a pre-commit hook (gitleaks,truffleHog, or the built-in DLP scanner atcore/security/dlp_scanner_v2.py) catches the common cases. -
Prefer the vault for developer credentials. GitHub PATs, Jira tokens, and Slack bot tokens belong in
bernstein connect <provider>. The env-var path is a migration compatibility shim, not the recommended steady state. -
Prefer external secrets managers for service credentials. If you already run Vault / AWS Secrets Manager / 1Password, point
secrets.providerat it rather than baking keys into a.env. Usevault_injector.pyfor anything that should live for one agent run only (database passwords, cloud roles). -
Rotate on a schedule.
bernstein creds test <provider>plus a cron entry catches expired tokens before the next ticket import fails. For provider keys without a rotate endpoint (Telegram, Jira), document the manual rotation in a runbook. -
Use
--passphrase-envcarefully. The file-backend passphrase is the master key for every credential the vault holds. Treat it the same way you would treat the secrets it protects: do not log it, do not check it into Ansible plaintext, do not put it in a shared.env. -
Multi-environment setups. Run separate vaults per environment (dev / staging / prod) by setting
BERNSTEIN_VAULT_PASSPHRASE_ENVto a different env-var name per environment, and pointing~/.config/bernstein/vault.encat a per-environment path with--file-path(or a different home directory). Do not share a single vault across environments — a leaked dev passphrase should never give access to production credentials. -
Mask, don't print. Any custom code that touches secrets should import
mask_secretandfingerprintfromcore.security.vault.resolverrather than rolling its own redaction. The vault's CLI output and audit log already use them.
Code pointers¶
| Concern | File |
|---|---|
bernstein connect/creds CLI | src/bernstein/cli/commands/creds_cmd.py |
| Vault protocol + dataclasses | src/bernstein/core/security/vault/protocol.py |
| Backend selector | src/bernstein/core/security/vault/factory.py |
| OS keychain backend | src/bernstein/core/security/vault/backend_keyring.py |
| AES-GCM file backend | src/bernstein/core/security/vault/backend_file.py |
| Provider registry + whoami | src/bernstein/core/security/vault/providers.py |
| Connect / test / revoke flow | src/bernstein/core/security/vault/connect.py |
| Vault-first resolver | src/bernstein/core/security/vault/resolver.py |
| HMAC audit log | src/bernstein/core/security/vault/audit.py |
| Startup secret-manager loader | src/bernstein/core/security/secrets.py |
| Spawn-time injector | src/bernstein/core/security/vault_injector.py |
| Env-var filter for spawns | src/bernstein/adapters/env_isolation.py (see env-isolation.md) |
Related¶
- Environment variable isolation — what gets filtered when Bernstein spawns an agent.
- Security & identity — JWT/OIDC/SAML, RBAC, audit log integrity at the API layer.
- Configuration — full list of
bernstein.yamlkeys includingsecrets.*. - Cloudflare setup — where the Cloudflare provider env vars are consumed.