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.

FlagApplies toMeaning
--instance dev|prodall but generatewhich instance's database and key to use; required, and refused if it is anything else
--name "<what it unlocks>"get, set, deletethe entry name; matched case-insensitively
--generatesetmint a new secret and store it; the plaintext is printed to stdout exactly once so the caller can apply it
--secret-stdinsetread the secret from stdin (trailing newline stripped) — the way to store a secret without it appearing in a command line
--secret-env VARsetread the secret from an environment variable; an empty variable is an error
--revealgetdecrypt and print the stored secret, and stamp the audit counters
--username, --url, --category, --notes, --tags, --by, --lengthsetmetadata; --by is agent or operator and defaults to agent
--fileall but generatean alternative secrets file to resolve credentials from

Behaviour worth relying on:

generating a secret stamps rotated_at, while omitting one keeps the stored secret untouched.

refused: an entry with nothing in it is not worth having.

wants a candidate before deciding where it goes.

secret unless --reveal is asked for**, and the reveal is announced on stderr as audited.

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

RouteContract
GET /api/vaultthe 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/vaultcreate; duplicate name is 409; body validated before anything is written
PATCH /api/vault/:idpartial update; a supplied or generated secret replaces and stamps rotated_at; unknown id is 404
POST /api/vault/:id/revealreturns {secret} and stamps the audit counters; 409 when nothing is stored or the blob cannot be decrypted (a rotated key)
DELETE /api/vault/:idpermanent; 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.

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 →