On this page
The rule
**Any password, token or key an agent generates or resets goes into the vault as part of the same step that creates it** — never only into a chat message, never into a file under version control, never into a code comment. The vault is the one place the operator looks for credentials, so a secret that exists anywhere else is a secret the operator will lose. Name the entry after what it unlocks, not after the task that produced it.
The CLI and the server routes are two faces of one implementation: the same row shape, the same SQL, the same encryption (lib/vault.js). Either is correct; the CLI is the path that works from inside a session without a browser.
The CLI contract
node scripts/vault.js <verb> [flags], with five verbs: list, get, set, delete, generate.
| Flag | Applies to | Meaning |
|---|---|---|
--instance dev|prod | all but generate | which instance's database and key to use; required, and refused if it is anything else |
--name "<what it unlocks>" | get, set, delete | the entry name; matched case-insensitively |
--generate | set | mint a new secret and store it; the plaintext is printed to stdout exactly once so the caller can apply it |
--secret-stdin | set | read the secret from stdin (trailing newline stripped) — the way to store a secret without it appearing in a command line |
--secret-env VAR | set | read the secret from an environment variable; an empty variable is an error |
--reveal | get | decrypt and print the stored secret, and stamp the audit counters |
--username, --url, --category, --notes, --tags, --by, --length | set | metadata; --by is agent or operator and defaults to agent |
--file | all but generate | an alternative secrets file to resolve credentials from |
Behaviour worth relying on:
setis an upsert by name. An existing entry is updated; supplying or
generating a secret stamps rotated_at, while omitting one keeps the stored secret untouched.
- A new entry with no
--generate,--secret-stdinor--secret-envis
refused: an entry with nothing in it is not worth having.
generateprints a password and stores nothing — useful when the operator
wants a candidate before deciding where it goes.
getprints metadata plussecret set (N chars). **No verb prints a stored
secret unless --reveal is asked for**, and the reveal is announced on stderr as audited.
- The CLI resolves the instance database URL and encryption key from the
environment or an out-of-worktree secrets file, and exits with a clear message when either is missing or the key is not 64 hex characters. It never writes a secret to a file of its own.
The REST surface
| Route | Contract |
|---|---|
GET /api/vault | the masked list, ordered by name; answers [] rather than an error when the vault is unavailable |
GET /api/vault/generate?length= | mints a password and stores nothing; an out-of-range length is 400 |
POST /api/vault | create; duplicate name is 409; body validated before anything is written |
PATCH /api/vault/:id | partial update; a supplied or generated secret replaces and stamps rotated_at; unknown id is 404 |
POST /api/vault/:id/reveal | returns {secret} and stamps the audit counters; 409 when nothing is stored or the blob cannot be decrypted (a rotated key) |
DELETE /api/vault/:id | permanent; 404 when the id is unknown |
When the vault table is absent or no encryption key is configured, the write routes answer 400 naming both possibilities rather than pretending to store anything. Nothing in this surface is entitlement-gated, but every route requires an authenticated session.
What is stored, and how
Rows live in commander.vault_entries: name, category, username, url, notes, tags, created_by, created_at/updated_at, rotated_at, last_revealed_at, reveal_count, and secret_enc. Only secret_enc is encrypted — everything else is a plain column, so listing never decrypts more than it must.
secret_enc is AES-256-GCM (NIST SP 800-38D) over a small JSON envelope, laid out as a 12-byte nonce, the 16-byte authentication tag, then the ciphertext. The key is a 32-byte value supplied as 64 hex characters through the instance's environment; both length and charset are validated, because a value that merely looks hex-shaped silently yields a short key.
Validation before any write: name length bound; category one of login, api_key, token, database, ssh, other (default login); url must be an absolute http(s) URL (RFC 3986); notes/username length-bound; tags lower-cased, de-duplicated and capped; created_by one of operator or agent; secret length bound; and a supplied secret together with a generate request is refused as ambiguous. Generated passwords are drawn uniformly by rejection sampling (no modulo bias) from an alphabet that omits look-alike characters, so an operator can read one aloud and retype it; the length is a tunable with a named default and a validated range.
The masked projection and the audited reveal
Every read path returns secret: { set, length, undecryptable } — whether a secret exists, how long it is (so the operator recognises "the 20-character one"), and whether the stored blob failed to decrypt. **The plaintext is never part of a list or a get.** It leaves the vault in exactly three situations: the one-time print of a freshly generated secret, an explicit --reveal, and an explicit reveal route call. Each reveal stamps last_revealed_at, increments reveal_count and writes a warning-level log line naming the entry and the reveal number, so the access history is visible afterwards. A revealed secret in a browser is re-masked after a configured interval.
undecryptable: true on an entry means the row outlived the key that wrote it. That is a diagnosis, not a bug to route around: the secret is unrecoverable from that row and must be reset at its source and stored again.
Storing what you just made
The sequence that satisfies the rule, in order: create or reset the credential at its source; store it with set (--generate when the vault should mint it, otherwise --secret-stdin so the value never appears in a command line or process list); name the entry for the thing it unlocks and set --url and --username so it is usable months later; then tell the operator that the credential is in the vault and under what name — not what it is.
Related skills
commander-security (session authority, the step-up guard, and why credential material is refused by the file routes), commander-mcp (per-session tokens, which are minted and revoked rather than stored here).
You've seen the proof
Ready for a number? Scope your deployment and we'll price it against your own economics.
Get your quote →