secrets
Scopes showed the folder tree that gates who can see whom. Secrets live in that same tree — the credentials an agent needs to reach out (a GitHub token, a model API key), with one hard rule: the agent uses them without ever seeing the plaintext in its prompt. arizuko stores them in the secrets table in routd.db and hands them to the right caller at the right moment. The kind of credential decides the path: model credentials enter the container env at spawn, because the Claude Code process itself spends them; capability credentials stay on the host, where the broker resolves each one per tool call. Scope — folder or user — decides who owns a row, not how it travels. That split is what keeps a leaked transcript from leaking a key.
secrets table, the three credential types below, both delivery paths, and the operator CLI. The container env carries the four model keys only — the operator's values from the host .env, with the triggering user's own rows for those keys overlaid. Every capability token is brokered on the host and never enters the container. Values are encrypted at rest — routd requires a SECRETS_KEY (see SECRETS_KEY). Model keys have their own dashboard section at /dash/me/env; capability tokens live at /dash/me/secrets, or arrive via OAuth at /dash/me/connections (GitHub today).three types
The key name decides the type, and the type decides the rules:
- Env-profile keys — the model credentials:
ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN,OPENAI_API_KEY,CODEX_API_KEY. These pick which model your spawn talks to, so they are user-scoped only — the store rejects them at folder scope. A user sets their own in a dedicated dashboard section at/dash/me/env, and their key overrides the operator default for their spawns. - Capability credentials — per-service tokens the agent uses to reach an external API:
GITHUB_TOKEN, a Cloudflare key, and the like. Set at/dash/me/secrets(user scope) or by the operator at folder scope. Grant-gated. When a connector call needs one, the broker resolves the triggering user's value on the host; the token never enters the container. - Infra / operator anchors — instance-wide values that live in the host
.env, not in this table:AUTHD_SERVICE_KEY, bot credentials,SECRETS_KEYitself. They belong to the operator running the box.
two scopes
For rows that live in the table (env-profile and capability), the scope_kind column says who owns the value:
- folder — tied to a group path like
atlas/eng. Owned by the operator; shared by every user that interacts with that folder. Env-profile keys are rejected here. - user — tied to one
auth_users.sub. Owned by the user (typed into/dash/me/envor/dash/me/secrets) or seeded by the operator as a fallback.
The primary key is (scope_kind, scope_id, key). Keys are uppercase env-style ids: ^[A-Z][A-Z0-9_]*$.
model credentials reach the container
At spawn time the container env holds the four env-profile keys and nothing else. The operator's values from the host .env are the floor, and the triggering user's own rows for those same keys overlay them — so a user's own ANTHROPIC_API_KEY shadows the operator's for that spawn, and a user without one still reaches the model.
routd narrows the set before it leaves the host: routd.DB.EnvProfileSecrets reads scope_kind='user' rows and keeps only those four names, so no other row can reach the env even by mistake. container/runner.go merges that map over the anchors it reads from .env.
The litellm package is the way to keep even the model credential out. The gateway container reads it from .env and forwards each request upstream with it; the agent container gets MODEL_GATEWAY_URL and MODEL_GATEWAY_KEY instead — a virtual key that only the gateway accepts, so a leaked transcript or a curious process in the container holds nothing that works anywhere else. One key per instance, not per spawn: litellm keeps per-key budgets in Postgres, which arizuko does not run.
capability credentials stay on the host
Capability credentials never enter the container, at either scope. They are resolved at tool-call time inside the host MCP dispatch chain. When a tool declares requires_secrets: ["GITHUB_TOKEN"], routd looks up the value and passes it as an argument to the handler running in the host process. The handler makes the outbound HTTP call, narrowed to the keys that call declared. The agent sees only the handler’s response, with the value scrubbed.
Folder resolution walks from the folder up to a synthetic root row, deepest wins, and the calling user’s own row overlays the result. So atlas/eng/sre sees its own keys over atlas/eng, over atlas, over root. A key set at atlas reaches every folder under atlas/ unless a child overrides it.
This is why a leaked agent transcript can’t leak a GitHub token — the token was never in the prompt and never in the container env.
getting a capability token without pasting one
Pasting a GITHUB_TOKEN at /dash/me/secrets works, but it means minting a long-lived personal access token and copying it by hand. Connect GitHub at /dash/me/connections runs the OAuth dance instead: you approve the arizuko OAuth app on GitHub, and the access + refresh token it hands back land in the same secrets row a pasted PAT would — same key, same broker, same delivery path. The broker refreshes the token near expiry, so you never see it again after the first approval.
This is surrogate OAuth: arizuko authenticates as you to GitHub's API, which is a different thing from the OAuth login you use to sign in to arizuko itself. Today it covers GitHub; more providers register the same way as they're added.
operator CLI
# folder-scoped
arizuko secret <instance> set <folder> KEY --value V
arizuko secret <instance> list <folder>
arizuko secret <instance> delete <folder> KEY
# user-scoped (fallback for users who haven't logged in yet)
arizuko user-secret <instance> set <user_sub> KEY --value V
arizuko user-secret <instance> list <user_sub>
arizuko user-secret <instance> delete <user_sub> KEY
Logged-in users manage their own user secrets through the dashboard at /dash/me/secrets — the CLI is the operator fallback for seeded values.
storage at rest
Secret values are encrypted at rest in the secrets table (AES-256-GCM). routd requires a SECRETS_KEY to seal and unseal them. The key, the keyring, and rotation are documented in SECRETS_KEY.
rotation
A second set with the same key upserts the value and bumps created_at. The next broker call picks up a new capability value; the next container spawn picks up a new model key. There is no restart-the-world step — an old model key lives in the env of a running container only until that turn ends.
what doesn’t happen
- No plaintext secret in audit logs. The
secret_use_logtable records key, scope, status, and latency — never the value. - No capability credential in container env. Folder and user capability values reach only the host-side broker.
- No skill or agent prompt can enumerate the table. Tools declare the keys they need; the broker resolves only those.
go deeper
The credential model — three types, resolution chain, write paths: specs/5/14. The surrogate OAuth write path: specs/5/15. Where secrets sit in the trust map: SECURITY.md. The folder hierarchy that drives resolution: scopes. External REST tools that inject a capability token: MCP reference.