Vault provision

Vault provision

index

colibri-vault fetches secrets from a Vaultwarden collection and writes them

into a freshly created jail as 0600 env-file. It is invoked as a post-spawn

hook from the daemon, not by a human operator at provision time. The human step

is registering a tenant mapping; the daemon does the secret fetch.

crates/colibri-vault/src/lib.rs

crates/colibri-daemon/src/daemon.rs (provision_tenant_env)

docs/VAULT-PROVISION-RUNBOOK.md

Decisions

Tenant = jail name = Vaultwarden collection

The tenants table stores a 1:1:1 map:

This means colibri-vault does not need a separate lookup table or configuration

file. It finds the collection by the jail name and knows the destination path

from the tenant row.

store-schema

Provisioning is a post-spawn hook, not a separate command

When the daemon spawns an agent with both --jail-name and --jail-root, it

calls provision_tenant_env after the jail is up. If the jail name has no

matching tenant row, the hook no-ops. If the provision fails, the agent still

starts, because a missing secret file should not leave the host with stale

partial jails. The daemon logs the failure.

crates/colibri-daemon/src/socket.rs (jail_provision_target)

Fail-soft on missing tenant or vault error

The hook returns early (and silently) when:

These are warnings, not hard errors. The spawn itself succeeds. This reflects the

operational reality that secret tooling may be unavailable during boot or

experimental spawns, while the agent process should still be observable.

Path containment before any write

colibri-vault::provision canonicalizes the target directory and asserts it is

strictly under the configured jail-root base (/usr/local/bastille/jails by

default, overridable with COLIBRI_JAIL_ROOT_BASE). The check runs before

create_dir_all, so a symlink or .. path that escapes the jails tree results

in TargetEscapesRoot before any file is created.

This is the same filesystem containment primitive reused by the external MCP

server spawner.

jail-confinement

Wrap the official bw CLI

We do not speak the Vaultwarden REST protocol directly. colibri-vault shells

out to the official bw CLI. This keeps authentication, session management, and

crypto off our plate.

The bw lifecycle is serialized across the process with a static Mutex because

bw keeps global state (one configured server and one session token per

process). Concurrent provisions would otherwise race on bw config server or

tear down each other's session.

Bootstrap creds come from the daemon environment

The daemon is expected to receive three variables from the operator-provided

provider environment file:

Optional:

The CLI never sees these values; it only registers the tenant row that triggers

the hook.

operator-cli

Server-mismatch is fail-closed

If BW_SERVER is set and bw is already logged in to a different server,

provision returns ServerMismatch. We do not wipe state automatically because

cross-server confusion could leak credentials. An operator must bw logout if

they want to switch servers.

Env-file content from login items and secure notes

Each Vaultwarden collection item becomes one or more KEY=VALUE lines:

Keys are validated to [A-Z0-9_] after normalizing spaces, dashes, and dots

to underscores. Invalid keys are skipped with a warning.

Note: a key collision between two items produces a duplicate line. The consumer

is expected to ignore duplicates or define items accordingly.

File mode and atomic-ish placement

The env file is written into the target directory and set to mode 0600. The

target directory is created if it does not exist, but it must already resolve

under the jail-root base. The write is a single std::fs::write, then a

permission change; it is not atomic-swap. If the daemon crashes between the

write and the chmod, the file could momentarily have looser permissions. For

now, we accept this because the daemon has the directory created immediately

before the write and the target is inside the jail.

Tenant status follows the provision state

register_tenant inserts the row with status = provisioned. After a successful

vault provision, the hook flips it to active. A stopped or destroyed jail may

later be moved to stopped or destroyed by the operator or a teardown flow.

Strictly, provisioned means the row is created; active means the secrets

have been materialized at least once.

Flow

register-tenant tenant_id jail_root collection_id

|

v

spawn-agent --jail-name tenant_id --jail-root jail_root

|

v

provision_tenant_env(tenant_id, jail_root)

|-- no tenant row -> no-op

|-- root mismatch -> warn, no-op

|-- else

v

bw login -> unlock -> list collection -> list items -> write env file @ 0600

|

v

set tenant status = active

agent starts running

See also

used to authorize agents to call mother