Run your own Agent Vault

Start the vault server with Docker, choose open or authenticated mode, and operate the data volume.

Start the server

docker run -d --name vault \
  -p 8080:8080 \
  -v vault-data:/data \
  registry.agentics.dk/agentics/pks-agent-vault:latest

GET /api/health answers {"status":"ok","mode":"open"} once it is up.

The image also mirrors to ghcr.io/pksorensen/pks-agent-vault. Both registries resolve to the same digest.

The data volume is the product

There is no database. /data holds everything, and everything in it is either ciphertext the server cannot read or metadata it can:

owners/<owner>/owner.json
owners/<owner>/index.json                                  rebuildable cache
owners/<owner>/audit/audit.jsonl                           hash-chained, append-only
owners/<owner>/vaults/<vaultId>/vault.json
owners/<owner>/vaults/<vaultId>/items/<itemId>/item.json
owners/<owner>/vaults/<vaultId>/items/<itemId>/sealed.json opaque, byte-for-byte

Back it up like any other volume. A backup contains no plaintext secrets, but it does contain item names, which are stored in the clear — an attacker who takes a backup learns what the secrets are called, not what they are. If that matters for a particular item, put a placeholder in the name and carry the real one inside the sealed blob.

Only one process may hold the volume: the server takes an exclusive flock on start. Two servers sharing a volume would each keep their own audit chain head and produce two divergent chains, so the second one refuses to start rather than silently forking the log.

Open mode versus authenticated mode

With OIDC_ISSUER unset the server runs open: no authentication, and the local principal is the administrator. That is a trusted-network and development affordance only. Do not expose an open-mode vault.

Setting OIDC_ISSUER turns on the OIDC port. One rule differs deliberately from the rest of the Agentics family, and it will stop your first deploy if you do not know about it:

With OIDC_ISSUER set and VAULT_ADMIN_SUBS empty, the process refuses to start.

Agent Inbox and Agent Consent read an empty admin list as "every authenticated caller is an admin". For those services that is defensible. Here it would mean that on a self-signup realm, anyone who registers can read every tenant's topology, mint invites on your domain, and purge vaults. So the vault fails closed. List the subjects explicitly:

docker run -d --name vault \
  -p 8080:8080 -v vault-data:/data \
  -e OIDC_ISSUER=https://keycloak.example.com/realms/agentics \
  -e OIDC_CLIENT_ID=agentics-vault \
  -e OIDC_CLIENT_SECRET=... \
  -e PUBLIC_BASE_URL=https://vault.example.com \
  -e VAULT_ADMIN_SUBS=8f2b...,c41d... \
  registry.agentics.dk/agentics/pks-agent-vault:latest

The mirror-image mistake is refused too: VAULT_ADMIN_SUBS set with no issuer means you believe a gate is running while nothing is authenticating, so the server rejects that combination rather than ignoring the list.

Environment

VariableDefaultDescription
HTTP_ADDR:8080Listen address for the JSON API
USER_DATA_DIR/dataThe data volume
OIDC_ISSUER(empty)Empty ⇒ open mode. Set to require authentication
OIDC_CLIENT_ID(empty)Required when OIDC_ISSUER is set
OIDC_CLIENT_SECRET(empty)Confidential-client secret
OIDC_SCOPES(empty)Extra scopes, comma-separated
PUBLIC_BASE_URL(empty)Required in auth mode; must be https:// (or http://localhost)
VAULT_ADMIN_SUBS(empty)Administrator OIDC subjects. Required in auth mode
VAULT_SELF_PROVISION(empty)true lets a signed-in caller claim one namespace of its own. Off is the right default for a self-hosted vault
CONSENT_URL(empty)pks-agent-consent base URL. Without it, no agent policy that asks for consent can release anything
CONSENT_OWNER(empty)The consent tenant to ask. Required alongside CONSENT_URL
LOG_LEVELinfodebug, info, warn, error

CONSENT_URL is not optional in practice. Leaving it empty does not turn the consent gate off — it fails closed, and every agent policy except the consentMode: never kind (which the server only accepts for a policy expiring within the hour) will refuse to release. That is deliberate: a gate that disappears when it is misconfigured is not a gate. The same applies to a consent service that is unreachable, erroring, or too old to support single-use approvals; the last of those is detected explicitly and reported as consent_incompatible, because an approval that can be replayed for thirty days is not the thing the vault asked for.

PUBLIC_BASE_URL is required in auth mode because proof-of-possession signatures are bound to the absolute request URL; a service that does not know its own public URL cannot verify them.

There is no web UI on this server

Browsing to / returns JSON, not a page, and that is deliberate. The client that seals and unwraps secrets is served from a different origin, because whoever serves a client's code decides what that code does with the plaintext. The one component whose claim is "it cannot read your secrets" must not also be the one choosing the code that reads them.

If you self-host, you supply the depositing client — either the keyholder CLI or your own page on your own origin. The server will never grow one.

Operating it

The admin tool ships in the same image, and its commands split in two by whether they write.

Reading is safe while the server runs. Every file is written with an atomic rename, so a reader always sees a whole file:

docker exec vault vault-admin --data /data owners
docker exec vault vault-admin --data /data audit verify acme
docker exec vault vault-admin --data /data audit head acme

Writing requires the server to be stopped. owner add and reindex take the volume's process lock, which the running server already holds; two writers on one volume would fork the audit chain into two histories no verifier can reconcile. Run them in their own container against the same volume:

docker run --rm -v vault-data:/data \
  registry.agentics.dk/agentics/pks-agent-vault:latest \
  vault-admin --data /data owner add acme --subs <oidc-sub> --label "Acme"

That is also how you create the first owner: do it before starting the server, or stop it first. Attempting it against a live volume fails with a message saying so rather than an flock errno.

Self-service namespaces

VAULT_SELF_PROVISION=true turns on POST /api/owners, which lets a signed-in caller claim one namespace bound to its own subject — what vault init uses to make installing the client the whole of setup. Leave it off and nothing changes: the route answers 404, and the CLI tells the user to ask you.

Think about it before turning it on, because it is two decisions, not one:

  • Anyone who can sign in to your realm gets a namespace. That is the same trade VAULT_ADMIN_SUBS exists to prevent for administration, so it is only reasonable when signing in already means something — a closed realm, or a hosted service that intends to be self-service.
  • Owner names become public the way handles are. A caller who may claim a name has to be told when it is taken, so POST /api/owners answers 409 on a name that exists. Nothing else in the API changes: every other route still answers the same 404 for a namespace that is absent and one that is not yours, so knowing a name is claimed still tells you nothing about what is in it or who holds it.

One subject may claim one namespace. Further ones are owner add, on the box, with a person deciding — which is the same rule as before, applied to the case where nobody has decided anything yet.

Verify the audit chain on a schedule and keep the printed head somewhere off the box. Chain verification catches any edit or deletion within the log; it cannot, by itself, catch someone deleting the newest segment entirely, because nothing on the disk records how far the chain had got and an attacker with write access would edit anything that claimed to. A head you exported earlier is what catches that.