Credential vault
A user-owned place for API keys and tokens that agents can use but never read. C3 decodes secrets at the subprocess boundary, so the value reaches your command and never reaches the model's context.
Overview
The usual way an agent gets a secret is the worst way: you paste it into the chat. From that moment the value lives in the conversation transcript β stored, indexed, searchable, and replayed into every subsequent request. Deleting the message does not un-index it.
The C3 vault removes that step. You store the secret once; the agent refers to it by name. When a command actually needs the value, C3 decodes it into the subprocess environment and nowhere else.
Write-only wire. No HTTP route in C3 β project server or Hub β ever returns a stored value. Entries are serialized through an explicit allowlist that structurally cannot emit one; rows carry a length and, on demand, a fingerprint. A canary test sweeps every credentials endpoint on every CI run and fails if a planted secret appears in any response body.
What this is not
- Not a team secret manager. It is local, single-user, and backed by your OS keyring. It does not sync, rotate, or issue short-lived credentials. If you need Vault or AWS Secrets Manager, use those β and store their bootstrap token here.
- Not a substitute for scoping your tokens. The vault controls where a value travels inside C3. It cannot reduce what the token itself is allowed to do.
- Not obfuscation dressed as encryption. Values sit in the OS keyring, which is exactly as strong as your OS login. Anything C3 could decode on its own would be reversible by anyone holding the code.
Quick start
Store it
The value is read from a hidden prompt β it never appears in your shell history.
c3 creds set NPM_TOKEN
Confirm it landed
Names and metadata only; the value is not printed.
c3 creds list
Use it
The agent names the secret; C3 injects it into the subprocess.
c3_shell(cmd='npm publish',
env_creds='NPM_TOKEN')
Anything shared across every project β a personal GitHub PAT, an OpenAI key β belongs in the global vault:
c3 creds set OPENAI_API_KEY --global
Bulk-import an existing .env file (each line becomes an entry of type env):
c3 creds import .env
c3 creds import .env --global
Importing does not delete the source file. Once the values are in the vault, remove the .env (or confirm it is gitignored) β otherwise you have simply added a second copy.
Where values live
| OS keyring | The default home for every value, under service c3-creds. Backed by Windows Credential Manager, macOS Keychain, or Linux Secret Service. |
.c3/secrets.enc |
Values larger than 1024 bytes (PEM keys, service-account JSON, .env blobs) β some keyring backends choke on long strings. Fernet-encrypted; the random master key is itself a keyring entry, so the file alone is useless. |
.c3/config.json |
The non-secret registry: name, scope, type, description, env var, flags, byte length, storage backend, created/updated timestamps. Never the value. |
.c3/cred_state.json |
Usage counters β last used, use count. Written when a value is actually resolved for injection. |
C3 adds secrets.enc and cred_state.json to .c3/.gitignore when the first credential is stored, so they cannot be committed even in projects that deliberately track .c3/.
Fingerprints
A fingerprint is the first 8 hex characters of sha256(value), computed live on request and never persisted. It answers "is the thing in the vault the thing I think it is?" β compare two machines, or confirm a rotation actually replaced the value β without revealing anything about it.
Scopes & overriding
| Scope | Lives in | Visible to |
|---|---|---|
| global | ~/.c3 | Every C3 project on this machine |
| project | <project>/.c3 | That project only β and it overrides a same-named global entry there |
Overriding is the mechanism for per-environment values. Keep a personal GITHUB_TOKEN in the global vault, then give one client project its own project-scoped GITHUB_TOKEN; work in that project resolves to the local one, everything else still gets the global.
The Hub shows the relationship from both directions: a project entry that wins over a global carries overrides global, and the global entry it beats carries overridden ΓN with the project names on hover.
Resolution is realm-atomic. A name registered in the project realm resolves in the project realm or not at all β it never silently falls through to the global vault. Without this, a hostile repository could commit a .c3/config.json registering the name of one of your global secrets and have C3 hand it over. There is a test for exactly this.
How agents use them
Three injection paths, all of which decode at the subprocess boundary:
1. Explicit env vars
c3_shell(cmd='npm publish', env_creds='NPM_TOKEN')
c3_shell(cmd='terraform apply', env_creds='AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY')
Each named entry is exported into the child process under its env_var (defaulting to the entry name).
2. Inline templates
c3_shell(cmd='curl -H "Authorization: Bearer {{cred:API_KEY}}" https://api.example.com')
{{cred:NAME}} is expanded server-side, after the model has produced the command string. The model writes the placeholder; it never writes the secret.
3. Auto-inject
An entry flagged inject is exported into every c3_shell run in its scope without being named. Convenient for a token every build step needs β and correspondingly broader, since any command that runs there can read it from the environment.
Echo redaction
Any value decoded during a session is tracked process-locally, and output is scrubbed on the way back: a command that echoes the secret returns [cred:NAME] instead. This is a safety net, not a guarantee β a transformed value (base64-encoded, split across lines, hashed) will not match and will pass through.
Cross-project shells run with credentials disabled. c3_project(action='shell') cannot inject another project's vault. Otherwise "operate on project B" would become a credential-exfiltration primitive.
Exposure flags
Two per-entry switches, both off by default, that control how far a value is allowed to travel. The Hub's settings drawer groups them under Exposure and spells out the blast radius of each.
| Flag | Effect | Cost |
|---|---|---|
inject |
Auto-export into every c3_shell subprocess in this scope |
Every command that runs there β including third-party tooling you did not write β can read it from the environment. |
agent_readable |
Permits c3_credentials(action='reveal') β the only value-returning action |
The plaintext enters the model's context and the conversation transcript, which are stored and searchable. Injection-only use does not need this flag. |
agent_readable is user-only, by design. An agent may set it when creating an entry, but it can never raise the flag on an entry that already exists. Otherwise the first thing a confused β or prompt-injected β agent would do is grant itself read access to everything. Raising it is a deliberate act you perform in the Hub UI or the CLI, and in the Hub it requires typing the credential's name.
Reach for agent_readable only when the agent genuinely must reason about the value itself β pasting a key into a config file it is authoring, say. For "run this command with my token", injection is both sufficient and strictly safer.
Hub UI
Run c3 hub and open the Credentials tab in the top bar. It manages the global vault and every registered project from one place. The same manager appears in each project's drill panel and in the per-project dashboard, so there is one interface to learn.
Two sub-tabs
| Global vault | The shared ~/.c3 store. Everything here is visible in every C3 project. |
| Projects | Every registered project, with its project-scoped entry count and β before you expand anything β badges for how many entries are agent-readable and how many override a global. Uninitialized projects are shown dimmed rather than hidden, so a missing .c3/ is visible instead of mysterious. |
One project expands at a time. The Projects list is a single-open accordion. Before v2.61.0 every expanded project mounted its own manager with its own fetch loop, so ten open projects meant ten independent views drifting out of sync with each other after any change.
The settings drawer
Click any credential β or press β΅ on a focused row β to open its settings drawer. This is the only surface that edits an existing entry or replaces a value.
| Section | Contents |
|---|---|
| General | Description, type, and the env var used at injection. The name is immutable β there is no rename; create a new entry and delete the old one. |
| Exposure | The inject and agent_readable switches, each with its blast radius written out. Turning one on requires confirmation; turning it off is immediate. |
| Secret | Check resolution probes whether the stored value still decodes and returns a fingerprint. Replace secret⦠opens a write-only field that starts empty, is never prefilled, and is cleared the moment the request settles. |
| Usage & relationships | Created / updated / last used / use count / storage backend, plus which projects override this name or are overridden by it. |
| Danger zone | Delete β visually separated from everything else. |
Context menu
Right-click any credential row for the full action set. The same menu opens from the β― button on the row, and from Shift+F10 or the Menu key when the row is focused. Arrow keys navigate it; Esc closes it.
| Item | Notes |
|---|---|
Open settings⦠| The drawer |
Check resolution | Resolvability + fingerprint |
Replace secret⦠| Opens the drawer with the replace field ready |
Enable / disable auto-inject | Confirmation required to enable |
Allow / revoke agent read | Enabling requires typing the credential name |
Copy name Β· env var Β· fingerprint | Fingerprint is available only after a check. There is no copy-value item β the value is not in the browser to copy. |
Open project drill | Jumps to that project's panel (project-scoped entries only) |
Delete credential⦠| Requires typing the credential name |
Destructive actions state their blast radius. Deleting shows where the value is stored, that anything resolving the name will start failing, and β for a global entry β how many projects override it locally and are therefore unaffected. The confirm button stays disabled until you type the credential's name.
Importing a .env
Import .env in the manager toolbar takes pasted KEY=VALUE lines. Comments and export prefixes are tolerated; each line becomes an entry of type env. Existing names are skipped rather than overwritten, and the response reports created and skipped counts by name.
Search & qualifiers
The search field sits above both sub-tabs and covers every project at once. Press / or Ctrl/β+K to focus it from anywhere on the page.
Results are grouped by credential name, with every definition of that name listed underneath β the global vault first, then each project that defines it. "Where is STRIPE_KEY configured, and which one wins?" is one glance rather than expanding forty accordions.
STRIPE_KEY Β· 3 definitions
Global vault global token Β· β’β’β’β’64
Payments API project overrides global
Billing worker project overrides global
Qualifiers
Bare words are matched (AND) against name, description, env var, and owning project. key:value terms narrow further and can be combined freely.
| Qualifier | Matches | Example |
|---|---|---|
project: | Project name or path (project:global for the vault) | project:payments |
scope: | global or project | scope:global |
type: | token, env, multiline | type:multiline |
storage: | keyring or file | storage:file |
name: | Credential name only | name:token |
env: | Injection env var | env:AWS |
inject: | Auto-inject flag | inject:true |
agent: | agent_readable flag | agent:true |
shadow: | Involved in an override, either direction | shadow:true |
Two audit queries worth knowing. agent:true lists every secret the agent is allowed to read into its transcript, across every project. inject:true lists every secret that lands in the environment of every shell command you run. Both are worth checking periodically.
Sorting & scope
Chips narrow results to All, Global, or Project. Sorts: name, most defined (widest sprawl first), last used, most used, and exposure β which floats agent_readable entries to the top, then inject. ββ walks the results and β΅ opens the highlighted one.
The same qualifier syntax works in the per-manager Filter box, which narrows the list you are already looking at instead of searching across projects.
Search indexes metadata only. The index is built from the same allowlisted fields the API returns β names, descriptions, env vars, flags, timestamps. Values are not in the browser, so they cannot be searched, and searching cannot leak them.
CLI commands
| Command | Description |
|---|---|
c3 creds set NAME | Create or update. The value is read from a hidden getpass prompt by default; --stdin accepts a piped or multiline value, and --value exists but puts the secret in your shell history. Metadata flags: --type {token,env,multiline}, --env-var, --desc, --inject, --agent-readable. --global targets the shared vault. |
c3 creds list | Merged view β project entries plus the globals visible here. Names and metadata only. |
c3 creds get NAME | Entry metadata, masked. --show prints the value to your terminal (not the agent's). |
c3 creds rm NAME | Delete the value and the registry entry. --global to target the shared vault. |
c3 creds import FILE | Import KEY=VALUE lines. Names already registered in the target scope are skipped unless you pass --overwrite. |
c3 creds get --show is the escape hatch for when you need to see a value β reading it into your own clipboard, not the agent's context. It is a local terminal command and has nothing to do with the agent_readable flag.
Tool actions
What the agent can call through c3_credentials. Read actions are safe in plan mode.
| Action | Group | Description |
|---|---|---|
list | Read | Merged registry β names, scope, type, flags, usage. Never values. |
describe | Read | Metadata, storage backend, and a live fingerprint. |
check | Read | Does the stored value still decode? |
set | Write | Create or replace an entry. agent_readable may be set at creation only. |
delete | Write | Remove the value and the registry entry. |
reveal | Gated | The only value-returning action β refused unless you enabled agent_readable on that entry. |
The full parameter reference lives on the tool card.
Audit trail
Every mutation and every reveal is logged by name β never by value.
| Project scope | The target project's .c3/activity_log.jsonl (event cred_action) and its edit ledger, as cred://<NAME>. |
| Global scope | ~/.c3/activity_log.jsonl, so the shared vault keeps its own trail independent of whichever project you were in. |
| Origin | Hub-initiated changes are tagged via: "hub", which distinguishes them from CLI and agent-initiated ones. |
To review what happened to one credential:
c3_edits(action='history', file='cred://NPM_TOKEN')
The vault is excluded from the Oracle Discovery API. External LLMs reaching C3 over MCP/HTTP cannot see credential tools at all β not the metadata, not the names. The exclusion is a hard exclusion, not a permission check.
Troubleshooting
| Symptom | Cause & fix |
|---|---|
The 'keyring' package is required |
pip install keyring. It is a declared dependency, but an in-place upgrade can leave it missing. |
Check reports β unresolvable |
The registry entry exists but the value is gone β usually a keyring reset, a new OS user profile, or a deleted .c3/secrets.enc. Re-store the value with c3 creds set NAME. |
| Hub shows not initialized | That project has no .c3/ directory. Run c3 init there. The Hub deliberately refuses to create .c3/ directories remotely (it returns 409 needs_init) rather than scattering them across your disk. |
| Injected variable is empty in the command | Check the entry's env_var β it defaults to the credential name, but if it was set to something else, that is the variable your command must read. |
reveal refused |
Working as designed: agent_readable is off. If the agent genuinely needs the value, turn it on yourself in the Hub drawer or with c3 creds set NAME --agent-readable. If it only needs to run something, use env_creds instead. |
| Global entry ignored in one project | That project has a project-scoped entry of the same name overriding it. Search shadow:true to see every override in play. |
The vault is deliberately small: services/credential_store.py holds the storage layer, and the write-only wire is enforced by an allowlist serializer plus an endpoint-sweep canary test. If you find a route that returns a value, that is a security bug β please report it.