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. A value spanning several lines inside one pair of quotes β a PEM key, a JSON service account β is imported whole and typed multiline; everything else becomes an entry of type env.
c3 creds import .env --dry-run
c3 creds import .env
c3 creds import .env --global
c3 creds import .env --only STRIPE_KEY,DB_URL
--dry-run prints a table of what would land β name, line, type, value length, fingerprint, and a reason for anything it will skip β and writes nothing. Skips are per-name and explained: already exists, not a usable credential name, no value, quote never closes, redefined later in the file.
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.
Structured kinds β cards, addresses, identity, logins (v2.87.0)
Four entry types hold named fields instead of one opaque value:
card (cardholder, number, expiry, optional cvc/billing_zip β Luhn-checked),
address (street1, city, state, zip, optional recipient/street2/country/phone),
identity (full_name, optional dob/ssn/phone/email) and
login (site_id, canonical_target, username, optional
password/private_key/passphrase/totp_secret β v2.90.0, generalized v2.118.0).
The payload is stored as one
canonical JSON object through the same keyring / encrypted-sidecar path as any other value.
They are inject-only by construction, which is stricter than a normal secret:
- Reveal is permanently disabled β
agent_readableandinjectare refused at every surface, for every caller, forever. There is no flag to flip. - Only single fields cross the boundary. The agent addresses a field β
env_creds='CARD.number'(exported as$CARD_NUMBER) or{{cred:CARD.number}}inline β and a bare{{cred:CARD}}fails with the field list rather than expanding the whole payload. - The registry shows a projection, not the data. Lists and the UI display
visa β’β’β’β’4242for a card (brand + last4, deliberately not expiry), city/state for an address, the name for an identity, andgithub Β· https://github.com Β· 2FAorbuild01 Β· ssh://build01.lan:22 Β· keyfor a login β site, target and 2FA/key booleans, with the username withheld on purpose, because username + target is half the credential. Field names are public; field values never transit any HTTP response β a sweep test enforces this. - Echoes redact per field to
[cred:NAME.field]. Caveat: the redactor only tracks values of 4+ characters, so a 3-digit CVC or 2-letter state that a child process echoes cannot be scrubbed after the fact. The real guarantee remains decode-at-the-subprocess-boundary; redaction is the belt over it. - The type boundary is immutable. A card cannot be rewritten into a
plain token (or vice versa) by
set, metadata update, or.envimport β delete and re-create is the only path across, and a keyring attestation keeps an entry structured even if.c3/config.jsonis edited by hand.
Updating one field never requires retyping the rest: a partial payload
merges ({"expiry": "01/30"} updates the expiry, JSON
null deletes an optional field). Read-back for you is terminal-only:
c3 creds get NAME --show (all fields) or --show --field number β
the browser and the mobile gateway never carry the values, and mobile cannot create
structured entries at all.
login is storage only, and that is a design constraint, not a
gap. C3 has no browser surface and must not grow one: a login runner in which the
agent picks the destination turns page-content prompt injection into credential
exfiltration, and a check written into a script that already holds the plaintext in its own
environment is not a boundary β the script can simply not call it. That is what
canonical_target is for: stored normalized
(scheme://host[:port], no path/query/fragment/userinfo, host lowercased), so a
separate out-of-process runner that the agent does not author can pin a credential to
exactly one destination and check where it is actually about to authenticate before typing.
That runner is deliberately not shipped in this package. totp_secret must be
base32 β a malformed seed produces wrong codes that look exactly like a wrong password.
Not just websites (v2.118.0). A login holds any target with
one unambiguous destination: ssh://build01.lan:22,
postgres://db.internal:5432, rdp://jump.corp,
smb://files.lan. The scheme comes from an allowlist (https, ssh, sftp, ftps,
rdp, smb, vnc, winrm, ldaps, imaps, smtps, amqps, postgres, mysql, mariadb, mssql, mongodb,
redis); cleartext schemes are refused by name and told which TLS variant to
use instead, for the same reason http:// always was. A server login may carry a
private_key (PEM/OpenSSH, checked, with its own size allowance and an optional
passphrase) instead of a password β one of the two is required, because a login
with neither is not a credential.
The origin-pinning property is preserved, not weakened:
canonical_origin is still a valid field name and still reads back β but
only when the target is https. A browser broker that pins a credential by
asking for canonical_origin gets nothing for an ssh:// entry and
fails closed, rather than being handed a string it cannot compare to a top-level frame.
Entries stored before v2.118.0 need no migration: the old field name is accepted on input
and resolves on read.
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, so those two are one interface.
The per-project dashboard is a lesser surface. The dashboard served by c3 serve has its own, older credentials panel: it can create, edit and delete entries and it has a page-level usage view the hub lacks, but it has no filters, no sort, no settings drawer, no context menu and no bulk actions. Everything below describes the hub manager. Use the hub, or a project's drill panel, for anything beyond a quick edit.
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 |
Four of these have bulk equivalents β see Filters & bulk actions. The two that enable exposure deliberately do not.
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 opens a chooser: pick a file, drop one on the panel, or paste KEY=VALUE lines. Comments and export prefixes are tolerated.
Nothing is written until you say so. Preview parses the file and shows a table of every row β name, line, type, value length, and a fingerprint β with anything it cannot import disabled and explained in place. Tick the rows you want and press Import. Replace entries that already exist is off by default, so a second import of the same file is a no-op rather than a silent overwrite.
Replacing an existing entry rotates its value and nothing else. The description you wrote, the custom env var, the type you chose, and the auto-inject and agent-read settings all survive. Before 2.93.0 an overwrite rebuilt the entry from scratch, which quietly turned auto-injection off.
If C3 read the file from a path on this machine, it remembers which file each entry came from β see Re-syncing a .env. A pasted body has no path to remember, so it records no source.
The preview never shows a value, not even the first few characters. A prefix is part of the secret. Rows are identified by length and a sha256 fingerprint, which is enough to tell two keys apart and to notice a truncated paste, and keeps the rule that a stored value never travels back to the browser.
Re-syncing a .env
A .env drifts all week; the first import is never the last one. When C3 imported entries from a path on this machine it records that path, and the manager grows an Imported from strip listing each remembered file with an entry count, when it was last synced, and a Re-sync button.
Re-sync re-reads the file on the server and compares every value against the stored one by digest, so it can tell you what changed without either value reaching the browser. Each row comes back as one of four answers:
| Row | Means |
|---|---|
unchanged | The vault already holds this exact value. Left unticked β rewriting it would be a keyring write and a ledger row for nothing. |
changed | The file and the vault differ. Ticked by default. |
new | In the file, not in the vault. Ticked by default. |
no longer in the file | Imported from this file once, and the file has since dropped it. Listed separately and never deleted. |
A key leaving a .env is not a signal to delete it. Plenty of credentials outlive the file that seeded them, so C3 reports the ones that vanished and stops there. If you do mean to remove them, select them in the list and delete them, where the confirmation can tell you what else that breaks.
Re-sync compares, it cannot adjudicate. It knows the file and the vault disagree; it does not know which one you changed. A value you edited in the vault by hand reads as changed, and re-syncing overwrites it with the file's. The file is the source of truth here β if the vault holds the newer value, update the file instead.
If the file changes between the preview and the import, the commit is refused rather than silently applying content you never saw: press Re-check to see the new answer.
Filters & bulk actions
Narrowing the list
Above every credential list is a row of chips β agent-readable, auto-inject, structured, shadowing, from .env, never used, stale >30d β each carrying a live count. Chips narrow: ticking two shows the rows matching both. They compose with the filter box and its key:value qualifiers, and / jumps to that box from anywhere on the page.
Sorting offers name, last used, most used, exposure and newest. Exposure floats the risky rows up: agent-readable counts double, auto-inject counts once.
Selecting rows
Select in the toolbar turns on checkboxes. Click a row to tick it, Shift-click to extend a range, or use the header checkbox to take everything currently shown β which is what makes the chips useful: filter to agent-readable, select all, revoke.
| Bulk action | Notes |
|---|---|
Check resolution | Read-only. Confirms each value can still be decoded. |
Revoke agent read | Sets agent_readable=false on every selected entry. |
Disable auto-inject | Sets inject=false on every selected entry. |
Export CSV | Metadata only β name, scope, project, type, storage, env var, exposure, usage, source, description. No values, because none are in the browser to export. |
Delete⦠| Requires typing DELETE <count>. |
Bulk can only ever reduce exposure. There is no bulk "allow agent read" and no bulk "enable auto-inject". Granting stays one entry at a time behind a confirmation that names it, because a bulk grant widens access to many secrets from a single checkbox and the one row you did not mean to include is exactly the one that matters. The server enforces this allowlist too β it is not merely a missing button.
Bulk rename, retype, storage migration and copy-to-global are absent for a different reason: each silently changes which credential a consumer resolves, and no dialog makes that visible after the fact.
Bulk delete says what else it breaks. Deleting a project entry does not remove the name β it hands it back to the global vault, so the project starts resolving the global value instead of failing. The confirmation lists exactly which of the selected rows do that, before you type the count.
A partial run reports as partial: the toast reads 7 ok, 2 failed and names the first failure, rather than claiming nine successes. Every mutation is logged to the ledger by (scope, project, name) β a name alone is ambiguous when the same name legitimately lives in the global vault and in several projects at once.
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 |
source: | Which .env an import recorded; source:none for the hand-made ones | source:.env |
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, including values quoted across several lines. Names already registered in the target scope are skipped unless you pass --overwrite, and each skip is reported with its reason. --dry-run shows what would land and writes nothing; --only NAME,... imports a subset; --global targets the shared vault. |
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.
Usage history (v2.88.0)
Every time a value leaves the vault β an env_creds injection, a
{{cred:NAME}} expansion, a gated reveal, a terminal
c3 creds get --show β one event is appended to the owning scope's
.c3/cred_usage.jsonl: timestamp, name (and field for structured entries),
action, surface, project, exit code, and the command in its raw template
form, capped at 120 characters. Never the expanded string, never a value.
A global credential's usage from every project lands in ~/.c3, so its
history is complete in one place.
Reading it back:
- UI β the Credentials tab's usage sub-view (totals, per-credential counts by surface, expandable recent events) and the hub drawer's βUsage & relationshipsβ section.
- CLI β
c3 creds usage [NAME] [--limit N] [--json]. - Agent β
c3_credentials(action='usage'). Scoped on purpose: the agent sees full events (including command previews) only for the current project; other projects' use of a shared global credential is reduced to counts, so one project's agent cannot read another's command lines through the vault. Your surfaces (UI/CLI) show everything. - REST β
GET /api/credentials/usageandGET /api/credentials/<name>/usage(hub:/api/projects/credentials/usage).
The log rotates at 512KB (one .1 generation retained), both filenames
are vault-write-protected and gitignored, and the lightweight
last_used/use_count counters you already see on list rows
keep working exactly as before β they are the summary; this is the history.
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. |
The Audit view
Those two logs answer different halves of the same question and, until v2.95.0, nothing joined them: changes live in the activity log, uses live in .c3/cred_usage.jsonl. Credentials → Audit in the hub merges them into one timeline, newest first, and every credential list has its own Audit button scoped to that vault.
| Column | What it tells you |
|---|---|
when | UTC timestamp of the event. |
kind | change (created, edited, deleted, imported, bulk action) or use. |
action | inject_env, template, reveal, cli_show, set, update, delete, import, batch_*. |
name | The credential, with the field for a structured kind (CARD.number). Click it to filter the timeline to that one key. |
where | Which project, or the global vault, plus the surface it came through β shell, cli, mcp, ui, hub. |
cmd | For a shell use, the command that needed it β expandable. |
exit | The command's exit code, so a credential used by a failing command is visible as such. |
The exposing filter is the one to reach for first. reveal and cli_show are the only two actions that put a plaintext value somewhere a person or a model can read it. Everything else hands the value to a subprocess and it is never surfaced. Those rows are badged and counted separately rather than left to be spotted in a list.
There is nothing to redact in the trail. Neither log has ever stored a value. cmd is the raw template you typed — {{cred:NPM_TOKEN}} or $NPM_TOKEN, never the substitution — which is why the command is shown in full instead of masked. Masking it would imply there was something behind the mask.
A project's timeline includes the global vault, because a shared credential used from a project records into ~/.c3 rather than the project — reading only the project would lose exactly the entries a shared secret generates. The cross-project view reads that shared vault once, not once per project.
From the terminal:
c3 creds audit # everything, newest first
c3 creds audit NPM_TOKEN # one credential's whole history
c3 creds audit --kind change # who changed what
c3 creds audit --action reveal # every plaintext read
c3 creds audit --since 2026-08-01 --json
Or, for the change half alone, through the edit ledger:
c3_edits(action='history', file='cred://NPM_TOKEN')
Change rows come from the live activity log. Entries older than its last rotation are in the archive and are not merged into this view.
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.