--- url: /install.md --- # Install the CLI The `enclavia` CLI is the primary entry point: authenticate, push images, create and manage enclaves. The source of truth is the public workspace at [`EnclaviaIO/enclavia`](https://github.com/EnclaviaIO/enclavia), published on crates.io as [`enclavia-cli`](https://crates.io/crates/enclavia-cli). There are three ways to install it, in roughly the order most users will reach for them. All produce the same `enclavia` binary; pick the one that matches the toolchain you already have on your machine. * [cargo install](#cargo-install): the published [`enclavia-cli`](https://crates.io/crates/enclavia-cli) crate on crates.io, if you already have a Rust toolchain. * [Nix](#nix): one command, no system dependencies to install. * [Build from source](#build-from-source): if you want to read or modify the code as you go. ## Common requirement Regardless of install method: * [Docker](https://docs.docker.com/engine/install/) — the CLI shells out to `docker tag` and `docker push` when you run `enclavia push`. ## cargo install The CLI is published on crates.io as [`enclavia-cli`](https://crates.io/crates/enclavia-cli); the installed binary is still called `enclavia` (the crate's `[[bin]]` name). ### Prerequisites * A reasonably recent Rust toolchain (stable channel, 1.85+). [rustup](https://rustup.rs) is the path of least resistance. * A C compiler and `pkg-config`. The CLI's HTTP client uses the system OpenSSL via `openssl-sys`, which builds against the host's libssl headers at install time. Concrete package lists, by OS: ```bash # Debian / Ubuntu sudo apt install pkg-config libssl-dev build-essential # Fedora / RHEL sudo dnf install pkg-config openssl-devel gcc # macOS (with Homebrew) brew install pkg-config openssl@3 ``` On macOS you may also need to point `openssl-sys` at the Homebrew install: ```bash export PKG_CONFIG_PATH="$(brew --prefix openssl@3)/lib/pkgconfig" ``` ### Install ```bash cargo install enclavia-cli ``` `~/.cargo/bin` should already be on your `$PATH` if you installed Rust via rustup. To upgrade later, re-run the same command: `cargo install` rebuilds when a newer version has been published. To run the latest unreleased code from git instead: ```bash cargo install --git https://github.com/EnclaviaIO/enclavia enclavia-cli ``` ## Nix A working [Nix](https://nixos.org/download) installation with flakes enabled is the only prerequisite. If your Nix config doesn't enable flakes by default, add this to `~/.config/nix/nix.conf`: ``` experimental-features = nix-command flakes ``` For a one-off invocation: ```bash nix run github:EnclaviaIO/enclavia#enclavia -- --help ``` Every `enclavia ...` example in these docs can be prefixed with `nix run github:EnclaviaIO/enclavia#enclavia --` if you'd rather not install the binary. To get a persistent `enclavia` on `$PATH`: ```bash nix profile install github:EnclaviaIO/enclavia#enclavia ``` To upgrade later: ```bash nix profile upgrade enclavia ``` ## Build from source If you'd rather have the repo on disk: ```bash git clone https://github.com/EnclaviaIO/enclavia cd enclavia cargo build --release -p enclavia-cli sudo install -m 0755 target/release/enclavia /usr/local/bin/enclavia ``` Same prerequisites as the `cargo install` path. The binary you want is at `target/release/enclavia`; the `install` step is optional but puts it somewhere on `$PATH`. ## Verify ```bash enclavia --help ``` You should see the top-level command list (`auth`, `enclave`, `push`, `reproduce`). If that prints, you're done; head to [Authenticate](/auth). ## Backend The CLI talks to the public beta backend at `https://api.beta.enclavia.io`. Credentials live under `~/.config/enclavia/` after `enclavia auth login`. --- --- url: /auth.md --- # Authenticate The CLI authenticates with the backend over OAuth 2.1 (PKCE-S256) with a localhost loopback redirect. There is no password to type into the terminal; the browser session is the source of trust. ## Sign in ```bash enclavia auth login ``` The command starts a tiny one-shot HTTP server on a random localhost port, prints the authorization URL, and tries to open it in your default browser: ``` Open this URL in your browser to authorize this device: https://api.beta.enclavia.io/oauth/authorize?response_type=code&client_id=enclavia-cli&redirect_uri=http://127.0.0.1:/cb&code_challenge=...&code_challenge_method=S256&state=... Waiting for the browser to redirect back... ``` If the auto-open fails (headless machine, no `xdg-open`, etc.) copy the URL into a browser that's signed in to your Enclavia account. Approve the consent screen and the browser redirects back to the loopback URL, which hands the authorization code to the CLI. The CLI exchanges the code for an access token + refresh token and writes both to `~/.config/enclavia/credentials.json`. If you don't yet have an Enclavia account, the consent flow redirects you through GitHub or Google sign-in first, then onboarding to choose a handle, then back to consent. ## Your handle Your **handle** is the user-facing identifier you chose during onboarding. It scopes every enclave's registry repo: each `enclavia enclave create` provisions a private repo at `registry.beta.enclavia.io//` that you then push to. Handles are not currently re-assignable, so pick one you're happy living with. You can confirm which account is currently authenticated by listing your enclaves — the request fails with a clear error if the token is invalid: ```bash enclavia enclave list ``` ## Re-authenticate Access tokens are short-lived and the CLI refreshes them silently using the refresh token in `credentials.json`. Sessions are revocable from the web UI; if a session is revoked, the next CLI command will print: ``` Error: unauthorized; run `enclavia auth login` to re-authenticate ``` Run `enclavia auth login` again and approve a new session. ### Upgrading from an older CLI Earlier CLI builds wrote a single-field credentials file (`{"token": "..."}`). The current CLI rejects that shape on startup and reports `unauthorized`. If you see that after upgrading, delete the file and re-authenticate: ```bash rm ~/.config/enclavia/credentials.json enclavia auth login ``` ## Different from the Claude / MCP login `enclavia auth login` only authorizes the **CLI on this laptop**. It is *not* the same login as the OAuth flow you go through when wiring up the [MCP connector](/mcp) in Claude (or ChatGPT, Cursor, Codex). Both flows present the same consent screen at `api.beta.enclavia.io` and tie back to the same Enclavia account, but each client ends up with its own session and its own bearer token: * **CLI** → token in `~/.config/enclavia/credentials.json`, used by every `enclavia` command (including `enclavia push`, which MCP intentionally doesn't expose). * **MCP client** → token held by the client (Claude, ChatGPT, …), used for `enclave_list`/`create`/`status`/`stop`/`destroy` tool calls. Authorizing one doesn't authorize the other. You can run the CLI without ever connecting an agent, or drive the management surface from an agent without installing the CLI. To go all the way from `create` to `running` you need both — the agent (or the CLI) creates the enclave, then `enclavia push` from your terminal uploads the image that flips it to `building`. --- --- url: /deploy.md --- # Deploy in one command `enclavia deploy` is the fastest way to get a local Docker image running inside an enclave. It rolls the whole flow into a single command: it creates the enclave, pushes your image into the enclave's registry repo, and then follows the build live (spinner, streamed build log) until the enclave is `running`. ```bash enclavia deploy myapp:v1 --name my-api --container-port 8080 ``` This is the **preferred path for humans** working interactively. If you are writing a script or driving the CLI from an AI agent, use the individual commands instead ([create](/create), [push](/push), then poll `enclave status`): each step then has its own JSON output and exit code, and nothing holds a process open for the length of a build. See [When not to use it](#when-not-to-use-it). ## What it does `deploy` takes one positional argument, the local Docker image, plus every flag that [`enclave create`](/create) accepts: 1. **Create.** Reserves the enclave with the flags you passed, exactly as `enclavia enclave create` would. 2. **Push.** Logs Docker into your registry, tags the image as the enclave's repo, pushes it, and notifies the backend, exactly as `enclavia push` would. 3. **Watch.** Polls the enclave until it reaches a terminal state. While waiting it shows a spinner with the current phase and elapsed time, and once the build starts it streams the build log so long builds never look stuck. On success it prints the enclave's ID, endpoint, and the PCR0/1/2 values to pin in your [client](/connect): ``` Enclave created: 1d2c3b4a-5e6f-7a8b-9c0d-1e2f3a4b5c6d The push refers to repository [registry.beta.enclavia.io/alice/1d2c3b4a-...] ... Build started; streaming the build log: building '/nix/store/...-enclave-rootfs.drv'... ... Build complete; launching the enclave... ✓ Deployed in 46s ID: 1d2c3b4a-5e6f-7a8b-9c0d-1e2f3a4b5c6d Name: my-api Endpoint: wss://1d2c3b4a-5e6f-7a8b-9c0d-1e2f3a4b5c6d.enclaves.beta.enclavia.io PCRs (pin these in your client): PCR0: ... PCR1: ... PCR2: ... ``` If the build fails, `deploy` prints the backend's error message and points you at `enclavia enclave logs ` for the full build log. ## Flags All [`enclave create` flags](/create#flags) work unchanged: `--instance-type`, `--container-port`, `--name`, `--storage-size-bytes`, `--visibility`, the `--egress-*` family, `--upgradable`, `--control-key`, `--min-upgrade-delay`, and so on. `deploy` is a superset of `create`; anything documented there applies here. ## Interrupting it The watch is read-only: **Ctrl-C stops the watch, never the build.** The create and push already happened, so the backend keeps building server-side. Re-attach at any time with: ```bash enclavia enclave status # current state enclavia enclave logs # build log so far ``` The same applies if the watch times out (it gives up after 45 minutes) or loses connectivity to the backend: the deploy itself is not rolled back, and `status` will tell you how it ended. ## When not to use it `deploy` is a convenience wrapper for interactive use. Prefer the individual commands when: * **You are scripting or running in CI.** `create`, `push`, and a `status` polling loop give you one JSON value and one exit code per step, so a failure is attributable to a specific stage. * **An AI agent is driving the CLI.** The [agent skill](/agent-skill) steers agents to the individual commands for the same reason: separately actionable steps, and no process held open for many minutes while a build runs. * **The enclave already exists.** `deploy` always creates a new enclave. To ship a new image to an existing upgradable enclave, use [`enclavia push`](/push) and the [staged upgrade flow](/upgrades). With `--json`, `deploy` still honours the [stdout contract](/agent-skill#the-json-contract): the spinner is disabled, all progress and build-log lines go to stderr, and stdout carries exactly one JSON value (the final enclave object). But scripts should prefer the individual commands anyway. ## Next * [Connect](/connect) to the running enclave with the printed PCRs. * [Create an enclave](/create) for the full flag reference and the create-then-push mechanics. * [Staged deployments](/upgrades) for shipping new versions to upgradable enclaves. --- --- url: /create.md --- # Create an enclave `enclavia enclave create` reserves an enclave id and provisions a dedicated private registry repo for it at `/`. The enclave starts in `waiting_for_image` and stays there until you `enclavia push` your container image into that repo. Builds are asynchronous; you poll for status. ::: tip Working interactively? [`enclavia deploy`](/deploy) rolls create, push, and the build watch into one command, and accepts every flag documented on this page. Prefer it when you're at a terminal; use the individual `create` / `push` steps below in scripts and agents. ::: ## The minimum ```bash enclavia enclave create ``` This reserves a `small` enclave with no persistent storage, no inbound HTTP port, and an auto-generated `--` display name. The output looks like: ``` Enclave created: ID: 1d2c3b4a-5e6f-7a8b-9c0d-1e2f3a4b5c6d Status: waiting_for_image Push your image to start the build: enclavia push 1d2c3b4a Check status with `enclavia enclave status 1d2c3b4a-5e6f-7a8b-9c0d-1e2f3a4b5c6d`. ``` The second argument to `enclavia push` is the enclave id (or any unique prefix that resolves to exactly one of your enclaves). The CLI tags your local image as `registry.beta.enclavia.io//:latest` and pushes it; the registry digest the push produces is what the backend pins the enclave to. See [Push an image](/push). ## A more typical example ```bash enclavia enclave create \ --instance-type small \ --container-port 8080 \ --name my-api \ --storage-size-bytes 268435456 ``` This reserves a `small` enclave with a 256 MiB encrypted volume, declares that the container listens on `127.0.0.1:8080` inside the enclave (so the proxy knows where to forward decrypted traffic), and labels the enclave `my-api` in the dashboard and `enclave list`. ## How create-then-push works Each enclave owns its own registry repo at `/`. `create` provisions that repo; the first successful push to it is what flips the enclave from `waiting_for_image` to `building`, and the digest the registry assigns becomes the enclave's pinned image (`docker_image` becomes `//@sha256:...`). For non-upgradable enclaves (the default), the enclave's identity is pinned to that digest for its lifetime. Pushing a different image later is rejected. To deploy a new version, `create` a fresh enclave and `push` your new image to it. For upgradable enclaves, subsequent pushes are staged and require an explicit confirm step before any version swap occurs. See [Staged deployments](/upgrades). ::: tip Iterating? You almost certainly want `--upgradable` Upgradability is a **create-time decision that cannot be changed later**, and the default is **non-upgradable**: the enclave is locked to the first image digest forever. That is the stricter, arguably better end state for a locked-down production deployment, but it is the wrong default while you are still developing. On a non-upgradable enclave, fixing a bug means `destroy`, `create` again, `push` the new image, and re-pin the new PCRs in every client, every single iteration. If you expect to push more than once, create with [`--upgradable`](#flags) up front. During development you can then use `enclavia upgrade confirm --immediate` to skip the default scheduling delay and cut over in seconds. Lock things down (create a fresh non-upgradable enclave, or add a [`--min-upgrade-delay`](/upgrades#minimum-upgrade-delay)) once the code has settled. See [Staged deployments](/upgrades) for the full flow. ::: ## Flags | Flag | Default | Purpose | |------|---------|---------| | `--instance-type ` | `small` | Resource tier. | | `--container-port ` | unset | Plaintext port the container listens on inside the enclave. The proxy forwards decrypted bytes to `127.0.0.1:` once the Noise channel is up. Required if you want the enclave to expose an HTTP service. | | `--storage-size-bytes ` | unset | Size of the persistent encrypted volume in bytes. Omit (or pass `0`) for a stateless enclave. Minimum is 128 MiB (`134217728`); the backend rejects anything smaller. | | `--name ` | auto-generated | Optional freeform display name (max 64 chars). Shown in the dashboard header and `enclave list`. Omit it to get an `--` name. | | `--visibility ` | `private` | Registry visibility for anonymous pulls. `public` lets anyone pull the enclave's image without auth, which is what makes `enclavia reproduce` work for non-owners. Owner pulls and pushes are governed by ownership and unaffected. | | `--egress-allow ` | unset (deny-all) | Permit one outbound destination. Repeatable. See [Outbound egress allowlist](/egress). | | `--egress-resolver ` | unset | DNS resolver(s) the in-enclave `unbound` forwards to. Required if any `--egress-allow` is a hostname. Repeatable. | | `--egress-config ` | unset | Path to a JSON allowlist file. Mutually exclusive with `--egress-allow` / `--egress-resolver`. See [Outbound egress allowlist](/egress#json-schema). | | `--upgradable` | off | Mark the enclave as upgradable. Future pushes are staged rather than rejected. Immutable post-create. See [Staged deployments](/upgrades). | | `--control-key ` | unset (managed) | Use self-hosted control-key custody: register the named local key (from `enclavia key generate --yubikey`) as this enclave's control key, so only your hardware can authorize upgrades. Implies `--upgradable`. Immutable post-create. See [Control-key custody](/custody). | | `--min-upgrade-delay ` | unset (no minimum) | Minimum delay between confirming an upgrade and it taking effect, e.g. `30m`, `48h`, `7d`, or a bare number of seconds. Baked into the measured image, so the enclave itself rejects any earlier activation, including `--immediate`, even from the control-key holder. Requires `--upgradable`. Maximum 90 days. Immutable post-create. See [Minimum upgrade delay](/upgrades#minimum-upgrade-delay). | ### Persistent storage When `--storage-size-bytes` is set, the backend provisions a LUKS2 volume on top of btrfs and mounts it inside the container at `/data` — that's where your app reads and writes. Pick a size in bytes; for example: ```bash enclavia enclave create --storage-size-bytes 1073741824 # 1 GiB enclavia enclave create --storage-size-bytes 134217728 # 128 MiB (minimum) ``` The volume is encrypted at rest. The LUKS passphrase lives in AWS KMS and is only released to the enclave after its attestation document matches the image's PCRs — so a stolen backing file is useless without the running, attested enclave. See [Push](/push) for why image tags are immutable. #### Durability: your process can be killed at any moment ::: danger fsync or lose it An enclave is **terminated hard**. There is no graceful shutdown: `enclave stop`, `enclave restart`, and an [upgrade](/upgrades) cutover all tear the enclave down from the outside without ever signalling your workload to flush and exit. Any write still sitting in the guest kernel's page cache when that happens is **silently lost**. ::: A plain buffered write (open, `write()`, close, or a high-level "write file" call in your language of choice) leaves the bytes in the kernel page cache and returns success long before they reach the encrypted volume. On a normal Linux box a background flush eventually persists them, but here the enclave can disappear between the `write()` and that flush, so the data never lands. The tell is a filesystem that looks unchanged after a restart (for example a btrfs `transid` that has not advanced), even though your application "wrote" the data. Neither `stop`/`restart` nor an upgrade cutover gives the guest a chance to `sync`: * `stop` and `restart` terminate the underlying instance directly (in the local dev/QEMU path, the VM process is killed; in production, the parent instance is stopped). The guest kernel is never told to power down, so it never flushes. * An upgrade cutover tears down the old version and boots the new one at `valid_from`. Same hard teardown. The fix is the same one you would use for any crash-tolerant program: **make every write durable before you consider it committed.** Do not rely on process exit, on closing the file, or on the enclave stopping cleanly, because it never stops cleanly. Language-agnostic durable-write recipe: 1. Write the new data to a **temporary file** in the same directory on `/data` (same directory so the final rename is atomic). 2. **`fsync` the file** you just wrote, and check that the `fsync` (and every preceding `write`) returned success. 3. **`rename`** the temp file over the final path. `rename` within a directory is atomic, so a reader (or the next boot) sees either the old file or the fully written new one, never a torn half. 4. **`fsync` the containing directory** so the rename itself is durable, not just the file contents. For a value you are updating in place (not a whole file), at minimum `fsync` the file descriptor after writing and before you treat the write as committed. Most databases and embedded stores (SQLite, LMDB, RocksDB, Postgres) already do this for you as long as they are configured to `fsync` on commit (the default in most, but confirm it) and their data directory is on `/data`; if you use one, let it own durability rather than hand-rolling file writes. The same rule applies to anything you have not persisted yet when a client connection drops: treat an enclave as a machine that can vanish without notice, and only acknowledge work once it is `fsync`ed to `/data`. #### Why Enclavia can't change the policy after the fact A reasonable follow-up: "Enclavia controls the AWS account that owns the KMS key — couldn't an admin (rogue or coerced) just edit the key policy to grant `kms:Decrypt` to themselves, retrieve the passphrase, and decrypt the volume outside the enclave?" The answer is no, and the mechanism is a quirk of how KMS key policies work that's worth spelling out: 1. **KMS key policies do not implicitly grant root access.** Unlike most AWS resource policies, the AWS account root principal only has the permissions a KMS key policy *explicitly* gives it. If a key policy doesn't list root, root cannot administer the key — full stop. ([Default key policy - AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/key-policy-default.html)) 2. **The key policy is created locked.** When the backend provisions the KMS key for a new enclave, it calls `CreateKey` with `BypassPolicyLockoutSafetyCheck=true` and a policy that grants `kms:Decrypt` *only* to principals presenting a Nitro attestation document with the image's PCRs, and grants `kms:PutKeyPolicy` / `kms:DeleteKey` to *no one*. The bypass flag is required because KMS normally rejects policies that would lock the key out of further management; we want exactly that lockout. ([PutKeyPolicy - AWS KMS](https://docs.aws.amazon.com/kms/latest/APIReference/API_PutKeyPolicy.html)) 3. **The policy is now immutable.** No principal — including Enclavia's AWS root, Enclavia engineers, AWS support, or anyone with `AdministratorAccess` in the account — can call `PutKeyPolicy` on this key, because the policy itself doesn't grant that permission to anyone. The key will continue to release the passphrase only to enclaves whose PCRs match, until it's eventually rotated as part of the upgrade flow. The trust boundary that protects your data is the policy that AWS KMS enforces on the key, not Enclavia's operational discipline. We deliberately set things up so that even we cannot grant ourselves access. Lifecycle: `enclave stop` keeps the encrypted volume around so the next start can re-mount it. `enclave destroy` removes the record and the volume. Both `stop` and `restart` are hard terminations with no in-guest flush (see [Durability](#durability-your-process-can-be-killed-at-any-moment) above): the persisted state you get back on the next start is exactly what your workload had `fsync`ed to `/data`, and nothing more. ## Timeout The backend keeps the enclave in `waiting_for_image` for up to **30 minutes** after `create`. If no push lands in that window the enclave moves to `error` with a `no fresh push detected within 30 minutes` message; you'll need to `create` a new one and push to it. ## Lifecycle commands ```bash enclavia enclave list # all your enclaves enclavia enclave status # detail: status, instance type, image, vsock CID, PCRs enclavia enclave logs # build log + (debug enclaves) runtime log enclavia enclave stop # stop a running enclave (terminates the instance, keeps storage) enclavia enclave start # boot a stopped enclave, re-mounting any provisioned storage enclavia enclave restart # server-side stop + start; applies pending secret changes enclavia enclave destroy # delete the enclave record (and any provisioned storage) ``` Every command that takes an enclave id (the lifecycle commands above, plus `push`, `reproduce`, and the `secret` and `upgrade` subcommands) accepts any unique prefix of it, resolved against your enclave list. If a prefix matches more than one enclave the command fails and lists the candidates. `status` shows populated PCRs (`pcr0`, `pcr1`, `pcr2` as hex) once the build completes. Those are the values you'll pin in the client when [connecting](/connect). `logs` prints two sections: the **build log** (the EIF build output, available once the build has started) and the **runtime log** (the guest serial console, captured only for debug/QEMU enclaves; production Nitro enclaves have no runtime log by design). With `--json` it emits the raw `{"build_log": ..., "runtime_log": ...}` object for piping. It's the first place to look when `status` shows `error` during a build or boot. ## Status meanings | Status | Meaning | |--------|---------| | `waiting_for_image` | Created and waiting for the first `enclavia push` to the enclave's registry repo. | | `building` | The backend is producing the enclave image (EIF) from your Docker image. | | `running` | The enclave is up. The proxy URL is `wss://.enclaves.beta.enclavia.io`. | | `stopped` | The instance is no longer running. The record (and storage if any) is preserved. | | `error` | Something failed; `error_message` in `enclave status` has details, and `enclave logs` has the full build log. | ## Connect Once the enclave is `running`, hand its ID and PCRs to the client library — see [Connect](/connect). --- --- url: /push.md --- # Push an image Enclavia runs each enclave from a Docker image hosted in a dedicated private repo at `registry.beta.enclavia.io//`. `enclavia push` is a thin wrapper around `docker tag` + `docker push` that handles registry login and the per-enclave namespacing for you. For a brand-new enclave you can skip the separate push entirely: [`enclavia deploy`](/deploy) runs create, push, and the build watch as one command. `push` on its own remains the right tool for scripts, agents, and for shipping a new image to an existing [upgradable](/upgrades) enclave. ## Prerequisites * You're [authenticated](/auth); `enclavia enclave list` returns without error. * You've [created an enclave](/create) and have its id (printed by `enclave create` and visible in `enclave list`). * Docker is running and can see the image you want to push. ## Push a local image The command takes two positional arguments: the local image to upload, and the id of the enclave you want to bind it to. ```bash enclavia push ``` For example, given a local image tagged `myapp:dev` and an enclave whose id starts with `1d2c3b4a`: ```bash enclavia push myapp:dev 1d2c3b4a ``` This: 1. Resolves `1d2c3b4a` against your enclaves; if it doesn't match exactly one of yours, the push fails before any I/O. Pass a full UUID if the prefix is ambiguous. 2. Asks the backend for your registry endpoint and a short-lived bearer token. 3. Logs Docker into `registry.beta.enclavia.io` with that token. 4. Tags `myapp:dev` as `registry.beta.enclavia.io//:latest` and pushes it. 5. Prints the manifest digest (`sha256:...`) the registry recorded; that's the content-addressed identifier the backend will pin the enclave to. 6. Notifies the backend that the push happened, so the waiting enclave starts building immediately. The backend also polls the registry as a fallback in case the notify is lost. The notify step uses the *push event itself* as the trigger, not just a manifest-digest change. That matters when you re-push an image whose layers the registry already cached: the registry returns the same digest, but the waiting enclave still picks up the push and starts its build. ## Enclave id grammar The second argument is the enclave id printed by `enclave create`, or any unique prefix that resolves to exactly one of your enclaves. A full UUID always works. The CLI never asks you to type the registry path or your handle; both are derived from the enclave id. ## One image per enclave (non-upgradable) By default an enclave is **non-upgradable**: it is bound at build time to the digest of whatever you first push to its repo. Pushing again to the same repo produces a new digest in the registry but is rejected with an error: ``` Error: this enclave is non-upgradable, create a new one ``` To deploy a new version, [create a fresh enclave](/create) and push to it. ## Staged deployments (upgradable enclaves) If the enclave was created with `--upgradable`, a second push does not deploy. Instead it stages the new image: the EIF is built but the running enclave is left untouched until you explicitly confirm the upgrade. ```bash enclavia push myapp:v2 1d2c3b4a # ... # Staged upgrade a3b4c5d6-... for enclave 1d2c3b4a-... # Confirm with: enclavia upgrade confirm 1d2c3b4a a3b4c5d6 ``` From there you can review the staged upgrade, schedule when it should fire, or revoke it before it takes effect. See [Staged deployments and the upgrade chain](/upgrades) for the full workflow. ## Pushing from CI `enclavia push` shells out to `docker login` against the bearer-token endpoint of the registry; nothing CI-specific is required beyond: * The `enclavia` binary on the runner (install via Nix, or `nix run github:EnclaviaIO/enclavia#enclavia --`). * A pre-approved API token (run `enclavia auth login` from a developer machine, copy `~/.config/enclavia/credentials.json` into the CI's secret store, restore it before invoking `enclavia push`). * Docker available to the CI job. ## Next Once your image is pushed the bound enclave starts building. Check progress with `enclavia enclave status `, then [connect to it](/connect) once it's `running`. --- --- url: /connect.md --- # Connect from a client There are two ways to talk to a running enclave, and the right one depends on who you trust to verify the attestation. **Embed the SDK in your client** (this page). Your code opens a WebSocket directly to `wss://.enclaves.beta.enclavia.io`, performs the Noise handshake itself, fetches the attestation document, validates the AWS Nitro signing chain, and pins the PCRs. **You are the verifier.** No third party can hand you tampered bytes without your client detecting it. Use this path when the client is yours to ship: a Rust binary, a wallet that compiles in the SDK, eventually a WASM build in the browser. **Go through the HTTPS proxy at `https://.enclaves.beta.enclavia.io/proxy/...`**. The proxy (we operate one on `*.enclaves.beta.enclavia.io`, or you can [self-host one](/self-host-proxy)) does the attestation verification on every request and tunnels plain HTTP/WebSocket to the enclave's workload. **The proxy operator is the verifier.** Use this path when you can't embed the SDK: an unmodified browser hitting a public URL, a curl pipeline, a client written in a language without a native enclavia SDK. PCR values are surfaced on every response as `X-Enclavia-PCR0..2` headers so a curious client can still check them out-of-band, but transport security between client and enclave reduces to "trust the proxy". See [Hosted HTTPS proxy](/proxy) for the user-side reference and [Self-host the proxy](/self-host-proxy) if you want to be the proxy operator yourself. The rest of this page covers the embed-the-SDK path. ## SDK overview Each running enclave is reachable at `wss://.enclaves.beta.enclavia.io`, the WebSocket-based proxy that bridges your client to the enclave's vsock channel. The client speaks Noise+CBOR directly to the in-enclave responder; the proxy is protocol-agnostic and never sees plaintext. The reference client is the Rust [`enclavia`](https://crates.io/crates/enclavia) crate, published on crates.io. It runs natively (Tokio) and also compiles to WebAssembly; the browser/Node packaging is on npm as [`@enclavia/client-wasm`](https://www.npmjs.com/package/@enclavia/client-wasm) (see [Browser and Node](#browser-and-node) below). ## Add the dependency ```toml # Cargo.toml [dependencies] enclavia = "0.1" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ``` The crate's public surface is small: `Client`, `ClientBuilder`, `Pcrs`, and a request builder. Optional `json` feature brings in `RequestBuilder::json`. ## Connect and verify ```rust use enclavia::{Client, Pcrs}; #[tokio::main] async fn main() -> Result<(), Box> { // Hex strings copied verbatim from `enclavia enclave status`. let pcrs = Pcrs::from_hex( "...your pcr0...", "...your pcr1...", "...your pcr2...", )?; let client = Client::connect( "wss://.enclaves.beta.enclavia.io", pcrs, ).await?; let resp = client.get("/health").send().await?; println!("{} — {}", resp.status(), resp.text()?); Ok(()) } ``` `Client::connect` does three things in one call: 1. Opens the WebSocket. 2. Performs a Noise NN (`Noise_NN_25519_ChaChaPoly_BLAKE2s`) handshake. 3. Requests an attestation document from the enclave and verifies the COSE\_Sign1 envelope, the AWS Nitro signing certificate chain, the handshake-hash binding (so this attestation can't be replayed against a different connection), and the PCR0/1/2 values you pinned. If any check fails, the call returns an error and no traffic flows. ## Get the PCRs you need to pin ```bash enclavia enclave status ``` The `PCRs:` block in the output is the source of truth. Pin those exact values; the client will refuse to connect to anything that doesn't measure to the same identity. PCRs are **per-enclave, not per-image** — the enclave's UUID is stamped into the rootfs at build time, so two enclaves created from the same Docker image have different PCR2 values. Pinning the PCRs from `enclave status` therefore binds your client to that specific enclave, not just to its image. If you destroy and re-create an enclave from the same image, you'll get a new set of PCRs to pin. ## Sending requests The request builder mirrors `reqwest`: ```rust let resp = client .post("/api/run") .header("Content-Type", "application/json") .body(r#"{"input": "..."}"#) .send() .await?; println!("status: {}", resp.status()); println!("body: {}", resp.text()?); ``` With the `json` feature, `RequestBuilder::json(&value)` serializes a `serde::Serialize` and sets `Content-Type: application/json` for you. The host header is filled in from the URL automatically. Each request is encrypted under the same Noise transport and forwarded plaintext to the inner container on the `--container-port` you specified at [create time](/create#flags). ## The connection does not auto-reconnect A `Client` holds a **single** long-lived attested WebSocket channel. It is opened once, at connect/build time, and the SDK does **not** re-dial it if it drops. There is no built-in reconnect loop, retry, or backoff in either the native or the WASM SDK. This is deliberate: re-establishing the channel means redoing the Noise handshake and re-verifying the attestation, and the SDK cannot know your retry policy or whether the enclave you were pinned to still has the same PCRs. When the channel drops (most commonly because the enclave [restarted or was upgraded](/create#lifecycle-commands), which tears the old connection down), the failure surfaces as an error on the request you attempted (in the native SDK, `Error::ConnectionClosed`; in WASM, a rejected promise). The `Client` is dead at that point: it will not recover, and subsequent requests on it also fail. **Reconnecting is the application's responsibility.** The pattern is: * **Connect lazily** and hold the `Client`, but be ready to throw it away. * On a dropped-channel error, **build a fresh `Client`** (which re-runs the handshake and attestation) and retry the request once. Beyond a single retry, apply your own backoff so a genuinely-down enclave does not spin. A minimal retry-on-drop wrapper, native Rust: ```rust async fn fetch_with_reconnect( url: &str, pcrs: &Pcrs, path: &str, ) -> Result> { for attempt in 0..2 { // Reconnect (or first connect) on each attempt. let client = Client::connect(url, pcrs.clone()).await?; match client.get(path).send().await { Ok(resp) => return Ok(resp.text()?), // Channel died mid-flight: drop this client and reconnect once. Err(e) if attempt == 0 => { eprintln!("channel dropped ({e}), reconnecting"); continue; } Err(e) => return Err(e.into()), } } unreachable!() } ``` The same shape in the WASM SDK (rebuild the client with `connect`, retry the `fetch` once): ```js async function fetchWithReconnect(url, pcrs, method, path, options) { for (let attempt = 0; attempt < 2; attempt++) { const client = await connect(url, pcrs, { debugMode: true }); try { return await client.fetch(method, path, options); } catch (e) { if (attempt === 0) continue; // channel dropped: reconnect once throw e; } } } ``` In a real app you would cache the `Client` between calls and only reconnect on failure, rather than reconnecting on every request as these minimal examples do. The load-bearing point is that a request can fail because the channel died, and the recovery is a fresh `connect`, not a retry on the same dead `Client`. ::: tip Expected right after a deploy or restart A dropped or failed client connection immediately after you restart, stop-then-start, or upgrade an enclave is **expected**, not a bug: the old attested channel went away with the old enclave. Reconnect (which re-verifies the new enclave's attestation) and carry on. If the enclave was [upgraded](/upgrades) to a new image, its PCRs also changed, so either re-pin the new values from `enclavia enclave status` or connect with `trustUpgrades` / `ClientBuilder::trust_upgrades` so the client follows the signed upgrade chain automatically. ::: ## Debug-mode enclaves If you're targeting a debug-mode enclave, the attestation document is a stub that echoes the handshake nonce instead of being COSE-signed. Use the builder explicitly: ```rust let client = Client::builder("wss://...local-debug-url...") .pcrs(Pcrs { pcr0: vec![], pcr1: vec![], pcr2: vec![] }) .debug_mode(true) .build() .await?; ``` `debug_mode(true)` only verifies the nonce binding — never use it against production enclaves. ## Browser and Node The same Rust core compiles to WebAssembly and is published on npm as [`@enclavia/client-wasm`](https://www.npmjs.com/package/@enclavia/client-wasm). It runs in browsers and in any JS runtime with a global `WebSocket` (Node 22+, Deno), and performs the same attestation verification as the native SDK, so the encrypted channel terminates in the user's browser and no proxy has to be trusted. ```bash npm install @enclavia/client-wasm ``` ```js import init, { connect } from "@enclavia/client-wasm"; await init(); // loads the wasm module (bundlers resolve the .wasm asset) const client = await connect( "wss://.enclaves.beta.enclavia.io", { pcr0: "...", pcr1: "...", pcr2: "..." }, // hex, from `enclavia enclave status` { debugMode: true }, // beta/QEMU only; omit on production Nitro ); const resp = await client.fetch("GET", "/health"); console.log(resp.status, new TextDecoder().decode(resp.body)); ``` ### The `fetch` signature `client.fetch` takes three arguments; the third carries request headers and a body, so it is not limited to bodyless GETs: ```js client.fetch(method, path, options?) => Promise<{ status, headers, body }> ``` * `method`: an HTTP method string (`"GET"`, `"POST"`, `"PUT"`, `"DELETE"`, `"PATCH"`, `"HEAD"`, `"OPTIONS"`; case-insensitive). * `path`: the request path, e.g. `"/api/run"`. * `options` (optional): * `headers`: an array of `[name, value]` string pairs. * `body`: a `Uint8Array` (encode strings/JSON yourself). The resolved response is `{ status: number, headers: [name, value][], body: Uint8Array }`. `body` is always raw bytes; decode it with `TextDecoder` (or `JSON.parse(new TextDecoder().decode(resp.body))` for JSON). A POST with a JSON body and a custom header: ```js const payload = new TextEncoder().encode(JSON.stringify({ input: "..." })); const resp = await client.fetch("POST", "/api/run", { headers: [ ["Content-Type", "application/json"], ["Authorization", "Bearer ..."], ], body: payload, }); if (resp.status !== 200) { throw new Error(`enclave returned ${resp.status}`); } const result = JSON.parse(new TextDecoder().decode(resp.body)); ``` There is no JSON convenience helper on the WASM side (the `json` feature is native-Rust only), so serialize the body and set `Content-Type` yourself as above. In Node (no bundler), pass the wasm bytes to `init` yourself: ```js import { readFileSync } from "node:fs"; import init, { connect } from "@enclavia/client-wasm"; await init({ module_or_path: readFileSync( new URL(import.meta.resolve("@enclavia/client-wasm/wasm")), ), }); ``` Non-HTTP protocols can use `client.openStream(firstBytes)` for a raw byte pipe over the same attested channel. `connect` also accepts `trustUpgrades: { backendUrl, enclaveId }`, mirroring the native `ClientBuilder::trust_upgrades`. See the [`enclavia-wasm` README](https://github.com/EnclaviaIO/enclavia/tree/master/enclavia-wasm) for the full surface and its two WebSocket-inherent differences from the native SDK. --- --- url: /mcp.md --- # Connect an AI agent with the MCP server Enclavia ships a [Model Context Protocol](https://modelcontextprotocol.io) server so you can manage your enclaves from any MCP-aware AI client — Claude, ChatGPT, Cursor, the OpenAI Codex CLI, or anything else that speaks the spec — using natural language. It's the same surface as the CLI (list enclaves, inspect status and logs, create, stop, destroy), exposed as MCP tools and authenticated against your Enclavia account. The MCP server is one of two ways to give an agent access. If your agent already runs in a terminal, the [CLI with `--json` plus the agent skill](/agent-skill) is more token-efficient and exposes the full command surface (including `push`, `secret`, `upgrade`, and `reproduce`). Reach for the hosted MCP server when you want zero local setup; reach for the CLI skill when the agent has a shell. The hosted endpoint for the public beta is: ``` https://mcp.beta.enclavia.io/mcp ``` It speaks the standard **Streamable HTTP** transport and authenticates via **OAuth 2.1 (PKCE-S256)** against `api.beta.enclavia.io`. Most clients will discover both automatically — paste the URL and follow the consent flow. ## Add the connector Pick the tab for your client. The OAuth flow is identical across all of them: you'll be redirected to `api.beta.enclavia.io` to authorize the connector against your Enclavia account (the same flow that backs `enclavia auth login`), then bounced back to your client with the connector linked. ::: tabs \== Claude 1. In Claude (claude.ai or Claude Desktop), open **Settings → Connectors → Add custom connector**. 2. Paste `https://mcp.beta.enclavia.io/mcp` into the connector field. 3. Save. Claude will redirect you to authorize. Approve the consent screen and you'll be bounced back to Claude. 4. Enable the connector in any chat to start using the tools. If the consent screen logs you in via GitHub or Google first, that's because your browser session at `beta.enclavia.io` had expired — sign back in, then re-trigger the connector and it will skip straight to consent. \== ChatGPT 1. In ChatGPT, open **Settings → Connectors → Add custom connector** (available on Plus, Pro, Team, and Enterprise). 2. Set the **MCP server URL** to `https://mcp.beta.enclavia.io/mcp`. 3. Set the **authentication** to **OAuth** — ChatGPT will discover the authorization server from the protected-resource metadata. No client ID or secret to paste; Dynamic Client Registration is supported. 4. Save and authorize. You'll be redirected to Enclavia, approve the consent screen, and ChatGPT will pick up the token automatically. 5. Enable the connector in a chat or a custom GPT and start asking it about your enclaves. \== Cursor Cursor reads MCP servers from `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` in your workspace. Add: ```json { "mcpServers": { "enclavia": { "url": "https://mcp.beta.enclavia.io/mcp" } } } ``` Restart Cursor. Open **Settings → MCP** and click **Authorize** next to the `enclavia` server — Cursor opens the OAuth flow in your browser. Approve, return to Cursor, and the tools become available in the chat panel. \== Codex CLI The [OpenAI Codex CLI](https://github.com/openai/codex) reads MCP servers from `~/.codex/config.toml`. Add: ```toml [mcp_servers.enclavia] url = "https://mcp.beta.enclavia.io/mcp" ``` Run `codex` — on first use it will print an OAuth URL; open it, authorize, and Codex stores the token in `~/.codex/auth.json`. Subsequent runs reconnect silently. \== Generic / other Any client that supports remote MCP over Streamable HTTP works. The raw parameters: | Parameter | Value | |---|---| | **MCP endpoint** | `https://mcp.beta.enclavia.io/mcp` | | **Transport** | Streamable HTTP | | **Authentication** | OAuth 2.1 (PKCE-S256, Dynamic Client Registration) | | **Authorization server** | `https://api.beta.enclavia.io` (discovered via `/.well-known/oauth-protected-resource`) | | **Audience** | `https://mcp.beta.enclavia.io` | | **Scopes** | none required — issued token covers all `enclave_*` tools | If your client supports stdio rather than HTTP, the same server binary speaks `--transport stdio` and reads a pre-issued token from the `ENCLAVIA_TOKEN` environment variable — useful for embedding into non-OAuth clients. Most users should use the hosted HTTP URL above. ::: ## What the agent can do The connector exposes one tool per CLI verb. Anything the agent calls runs against your account, scoped by the OAuth token issued during the authorization step. Tools currently available: | Tool | Equivalent CLI | |------|----------------| | `enclave_list` | `enclavia enclave list` | | `enclave_status` | `enclavia enclave status ` | | `enclave_logs` | `enclavia enclave logs ` | | `enclave_create` | `enclavia enclave create [--instance-type ... --container-port ... --storage-size-bytes ...]` | | `enclave_start` | `enclavia enclave start ` | | `enclave_stop` | `enclavia enclave stop ` | | `enclave_destroy` | `enclavia enclave destroy ` | | `upgrade_chain` | `enclavia upgrade chain ` | | `upgrade_list` | `enclavia upgrade list ` | | `upgrade_status` | polls one staged upgrade (`enclavia upgrade list ` shows the same fields) | | `upgrade_confirm` | `enclavia upgrade confirm ` (managed custody only) | | `upgrade_revoke` | `enclavia upgrade revoke ` (managed custody only) | A useful prompt to verify the connector is wired up: > List my enclaves and tell me which are running. The agent will call `enclave_list` and summarise. If you're brand new, ask the agent to create an enclave for you — it will reserve one in your account and tell you the `enclavia push` command to run next. See [Sample apps](/samples) for ready-to-push images that take you from `waiting_for_image` to `running` in a few minutes. ## Scope and authentication * **Identity** is established via OAuth 2.1 (PKCE-S256) against `api.beta.enclavia.io`. The MCP server itself never sees your password or upstream identity-provider token — it only receives the API JWT minted by the backend, attached to each tool call as a `Authorization: Bearer ` header on the inbound MCP request. * **Multi-tenant by design.** The MCP server holds no per-user secrets. Two different agent sessions authorized by two different users hit the same process and only see their own enclaves. * **Revoking access** takes one click: open the dashboard at `beta.enclavia.io`, find the active session for `enclavia-mcp` under your sessions list, and revoke it. Subsequent tool calls from that connector will fail with `unauthorized` and the agent will offer to re-authorize. ### Separate from the CLI login The MCP connector login and `enclavia auth login` ([Authenticate](/auth)) are **two distinct logins against the same Enclavia account**. They share the same consent screen at `api.beta.enclavia.io`, which is what makes them feel like a single flow, but each client (Claude, ChatGPT, your terminal, …) ends up with its own bearer token tied to its own session: | Where the token lives | What it authorizes | |---|---| | Inside your MCP client (Claude, ChatGPT, Cursor, Codex) | Tool calls from the agent: `enclave_list`, `enclave_create`, etc. | | `~/.config/enclavia/credentials.json` on your laptop | The `enclavia` CLI binary, including `enclavia push` (which the MCP server intentionally doesn't expose). | You can authorize one without the other. Common patterns: drive the management surface from an agent and never install the CLI (you skip `push` and reproduce, but the rest works); or run the CLI only and skip the MCP connector entirely. To go all the way from "create" to "running" you need both — the agent creates and inspects, the CLI pushes the image that flips the enclave to `building`. ## Limitations * `enclavia push` is **not** exposed as an MCP tool. Pushing requires a Docker daemon and a local image, both of which live on your machine, not in the MCP server. Ask the agent to create the enclave first (it reserves a private repo for it in your namespace); then push to that enclave's id from the CLI to trigger the build. * The MCP server doesn't proxy traffic into running enclaves. To talk to an enclave's HTTP service you still use the [`enclavia` client library](/connect). ## See also * [Drive enclavia from a local AI agent (CLI skill)](/agent-skill) — the lower-overhead alternative for agents that have a shell, with the full CLI surface. --- --- url: /agent-skill.md --- # Drive enclavia from a local AI agent (CLI skill) There are two ways to let an AI agent manage your enclaves, and they trade off in opposite directions: 1. **The hosted [MCP server](/mcp)** at `mcp.beta.enclavia.io`. Zero local setup, OAuth in the client, a per-request bearer token. Best for agents that run in someone else's runtime (Claude on the web, ChatGPT) and have no shell of their own. 2. **The `enclavia` CLI with `--json` plus the agent skill** (this page). The agent runs the `enclavia` binary locally and reads a short skill file that teaches it the command surface. It needs a shell and a seeded credentials file, but it is more token-efficient than the MCP server for the same operations (one CLI call returns one JSON value, instead of an MCP tool round-trip), and it exposes the local-tool commands the hosted MCP server cannot offer: `push` (needs local Docker), `reproduce` (needs the local builder and Nix), and `secret` management. Both cover the enclave lifecycle, `logs`, and `upgrade`. If your agent already has a terminal, prefer this path. If it does not, use the MCP server. ::: warning Agents should not use `enclavia deploy` [`enclavia deploy`](/deploy) is a human convenience that wraps create + push + a long-lived watch (spinner, streamed build log) in one process. Agents should run the individual commands instead: `enclave create`, `push`, then poll `enclave status`. That keeps each step's JSON output and exit code separately actionable and avoids holding a process open for the many minutes a build can take. The skill file already steers agents this way. ::: ## The `--json` contract `--json` is a global flag: it works on every subcommand and in either position (`enclavia --json enclave list` or `enclavia enclave list --json`). It turns the CLI into a clean, scriptable surface. The contract an agent relies on: * **Success**: a single JSON value (object or array) is printed to **stdout** and the process exits `0`. * **Failure**: a single `{"error": "", "kind": ""}` object is printed to stdout and the process exits **non-zero**. `kind` is one of `not_logged_in`, `unauthorized`, or `error`. * **Progress and prompts** (including the OAuth login URL) and any diagnostics go to **stderr**, never stdout. So stdout is always exactly one parseable JSON value. The agent therefore parses stdout once and branches on the **exit code**, not on prose: ```bash out=$(enclavia enclave list --json) # capture stdout if [ $? -eq 0 ]; then # $out is the success array of enclave objects else # $out is {"error": ..., "kind": ...}; inspect .kind fi ``` ### The `reproduce` exception [`enclavia reproduce`](/reproduce) is a verification command, so its PCR verdict maps onto the exit code the way `diff` or `test` do: | Exit code | Meaning | |---|---| | `0` | Reproducible: the local rebuild's PCRs match the recorded build. | | `2` | Diverged: the build ran but the PCRs do not match. | | `1` | Operational error (the usual `{"error", "kind"}` shape). | On both `0` and `2` the full reproduce payload (including `reproducible` and the `mismatches` array) is printed to stdout, so a field-reading agent gets the detail while an exit-code-only caller still fails closed. Gate on exit `0` (or `reproducible == true`). ## The agent skill The skill is a [Claude Code skill](https://docs.claude.com/en/docs/claude-code/skills): a single `SKILL.md` file with YAML frontmatter that an agent loads when a task matches its description. It teaches the agent the `enclavia` command surface, the `--json` rule above, and the per-command output shapes, so the agent does not have to rediscover them. It lives in the public workspace at [`skills/enclavia/SKILL.md`](https://github.com/EnclaviaIO/enclavia/blob/master/skills/enclavia/SKILL.md) in the [`EnclaviaIO/enclavia`](https://github.com/EnclaviaIO/enclavia) repo, next to the CLI source it documents. ### Install it for your agent Copy the file into your agent's skills directory. For Claude Code that is `~/.claude/skills/enclavia/`: ```bash mkdir -p ~/.claude/skills/enclavia curl -fsSL https://raw.githubusercontent.com/EnclaviaIO/enclavia/master/skills/enclavia/SKILL.md \ -o ~/.claude/skills/enclavia/SKILL.md ``` If you already have the repo checked out, copy it from there instead: ```bash mkdir -p ~/.claude/skills/enclavia cp path/to/enclavia/skills/enclavia/SKILL.md ~/.claude/skills/enclavia/SKILL.md ``` The agent picks the skill up on its next run. From then on, asking it to "deploy", "list", or "inspect" an Enclavia enclave routes through the skill and the `--json` CLI. ## Authentication for headless agents The CLI reads credentials from `~/.config/enclavia/credentials.json` (honouring `$XDG_CONFIG_HOME`). That file holds an OAuth access token plus a refresh token; the CLI auto-refreshes on expiry and rewrites the file, so once it exists an agent keeps working with no further interaction. The catch is how the file gets created. `enclavia auth login` is **interactive**: it opens a browser (OAuth 2.1 + PKCE) and prints the approval URL to stderr. A headless agent cannot complete it. The flow is therefore: 1. A human runs `enclavia auth login` once on a machine with a browser (see [Authenticate](/auth)). 2. Copy the resulting `~/.config/enclavia/credentials.json` to the agent's `~/.config/enclavia/` if the agent runs somewhere else. 3. Run the agent with that file present. If no credentials exist, every command fails with `{"kind": "not_logged_in"}`. To target a non-production backend, set `ENCLAVIA_BACKEND_URL` (default `https://api.beta.enclavia.io`), for example `http://localhost:3000`. Note that the credentials file also records the backend it was minted against and uses it as the base URL, so keep the two consistent: a credentials file from one backend will not authenticate against another. ## Which should I use? | | [MCP server](/mcp) | CLI + `--json` + skill | |---|---|---| | **Setup** | None local; paste a URL, OAuth in the client | Install the `enclavia` binary, seed credentials, drop in the skill | | **Auth** | Per-request bearer token, OAuth in the client | Human-seeded `credentials.json`, auto-refreshed | | **Best for** | Hosted agents with no shell (Claude web, ChatGPT) | Local agents that already have a terminal | | **Token overhead** | Higher (MCP tool round-trips) | Lower (one CLI call, one JSON value) | | **Surface** | Enclave lifecycle (`create`/`list`/`status`/`start`/`stop`/`destroy`), `logs`, and `upgrade` | Enclave lifecycle (plus `restart`), `logs`, and `upgrade`, plus `push`, `secret`, and `reproduce` | In short: reach for the **MCP server** when you want a hosted connector with no local setup, and for the **CLI + skill** when your agent already has a shell and you want the lower token overhead plus the local-tool commands (`push`, `reproduce`, `secret`). ## See also * [Connect an AI agent with the MCP server](/mcp) — the hosted alternative to this page. * [Authenticate](/auth) — the interactive login that seeds the credentials file. * [Reproduce a build](/reproduce) — the verification command whose exit codes the skill special-cases. --- --- url: /egress.md --- # Outbound network access (egress allowlist) By default a running enclave has **no outbound network**. Nothing the workload writes leaves the VM, and no library will see "connection succeeded" against any external host. This is intentional: an enclave's value comes from being able to *prove* what it does with your data, and unconstrained egress would let a workload silently exfiltrate. You opt in to outbound traffic by declaring an **allowlist** at create time. The allowlist is a list of destinations (hostnames, IPv4 literals, or IPv4 CIDRs, each scoped to a port and protocol). It is baked into the enclave image at build time and **covered by the PCRs**: an auditor running `enclavia reproduce` sees the exact set of destinations the workload can reach, and any change to that set changes the enclave's identity. ## How it works (one screen) ``` workload ──┐ │ writes packets to /dev/net/tun (the workload's default route) ▼ tun0 (inside the enclave) │ userspace TCP/IP stack (smoltcp) terminates the connection ▼ egress filter (deny-all by default; allow only what the policy permits) │ permitted flows are forwarded as length-prefixed CBOR frames ▼ vsock to host (port 5006) │ ▼ egress-host (host-side relay; dials the upstream IP and splices bytes) ``` The workload itself doesn't need to know any of this. It opens a TCP socket like it normally would; the daemon decides whether to let the connection complete. Hostname entries are resolved by a validating `unbound` running inside the enclave on `127.0.0.1:53` (DNSSEC, allowlist-aware); the workload's `/etc/resolv.conf` is auto-written to point at it. ## Trust model The trust boundary for egress is the **in-enclave filter**, not the host. The host-side relay (`egress-host`) trusts the enclave: the enclave decides which destinations to dial, and the host obeys. That's safe because the filter, the resolver, and `/etc/enclavia/egress.json` all live in the EIF rootfs, which is hashed into PCR2. If anyone tampers with the policy between build and boot, the PCRs change and clients pinning the original PCRs refuse to connect. The practical consequence: **what the auditor sees in `enclavia reproduce` is what the enclave can reach.** No out-of-band policy, no separate firewall to audit. ## The CLI `enclavia enclave create` takes three flags. Use the per-entry flags for ad-hoc allowlists, the file form for anything non-trivial. ### Per-entry flags ```bash enclavia enclave create \ --egress-allow api.openai.com:443 \ --egress-resolver 1.1.1.1 ``` (The build kicks off only when you `enclavia push` your image to the enclave; see [Create](/create) for the full create-then-push flow.) | Flag | Form | Notes | |------|------|-------| | `--egress-allow` | `HOST:PORT`, repeatable | `HOST` is a hostname, IPv4 literal, or IPv4 CIDR. TCP only today. | | `--egress-resolver` | `IPV4`, repeatable | DNS resolver(s) the in-enclave `unbound` forwards to. Required if any `--egress-allow` is a hostname. | | `--egress-config` | `PATH` | JSON file matching the [schema below](#json-schema). Mutually exclusive with the two flags above. | Three worked examples: ```bash # Hostname target. Needs a resolver because the daemon has to learn # api.openai.com's A records at connect time. enclavia enclave create \ --egress-allow api.openai.com:443 \ --egress-resolver 1.1.1.1 # CIDR target. No resolver needed (the daemon is matching IP literals). enclavia enclave create \ --egress-allow 10.0.0.0/8:443 ``` Omit all three flags and the enclave is back to its pre-egress behaviour: no outbound network. This is the default, and it matches the behaviour of any enclave created before this feature shipped. ### JSON schema For non-trivial allowlists, drop a JSON file alongside your project and pass it with `--egress-config`. The canonical schema: ```json { "version": 1, "resolvers": ["1.1.1.1", "8.8.8.8"], "egress": [ { "host": "api.openai.com", "port": 443, "protocol": "tcp" }, { "host": "10.0.0.0/8", "port": 443, "protocol": "tcp" } ] } ``` Validation rules (enforced identically by the CLI, the backend, and the in-enclave daemon, all calling into the same `enclavia-egress` parser): | Field | Rule | |-------|------| | `version` | Must be `1`. Future schemas bump this and the CLI/backend negotiate. | | `resolvers[]` | IPv4 literals. Required if any `egress[].host` is a hostname; can be empty if you only allowlist IPs/CIDRs. | | `egress[].host` | RFC 1035 hostname, an IPv4 literal, or an IPv4 CIDR (`a.b.c.d/n`). | | `egress[].port` | `1..65535`. | | `egress[].protocol` | `"tcp"`. The only supported protocol today; the schema reserves room for `"udp"` in the type but actively rejects it at validation. | | IPv6 | Rejected at every layer; there's no v6 path through the daemon. | ```bash enclavia enclave create --egress-config ./egress.json ``` The CLI rejects mixing `--egress-config` with `--egress-allow` / `--egress-resolver` so there's no ambiguity about which document gets baked into the EIF. ## The API The same shape is exposed on the REST API. `POST /enclaves` accepts an optional `egress_allowlist` body field whose JSON matches the schema above: ```jsonc POST /enclaves { "instance_type": "small", "container_port": 8080, "egress_allowlist": { "version": 1, "resolvers": ["1.1.1.1"], "egress": [ { "host": "api.openai.com", "port": 443, "protocol": "tcp" } ] } } ``` Validation runs server-side before the build kicks off; an invalid document fails the request with a pointed error rather than failing the EIF build later. The persisted value is returned as-is on every `GET /enclaves/{id}`, which is what powers `enclavia reproduce`. The MCP `enclave_create` tool takes the same `egress_allowlist` field. The MCP server doesn't have access to your filesystem, so there's no path-based variant; pass the document inline. See [Connect an AI agent (MCP)](/mcp). ## The dashboard On `beta.enclavia.io`, the **Create enclave** form has an *Egress allowlist* section. Empty form means deny-all (the default). Filled in, it accepts the same per-entry grammar as the CLI (`host:port[/proto]` lines) plus a list of resolver IPs. The frontend does a syntactic pre-flight check; the backend's validator remains the authoritative gate. ## `enclavia reproduce` and PCR pinning The allowlist lives at `/etc/enclavia/egress.json` inside the enclave's rootfs, so it's hashed into PCR2. Two consequences: 1. **The auditor sees the policy.** `enclavia reproduce ` rebuilds the EIF locally from the same image and the same allowlist the backend recorded, and confirms the local PCRs match. See [Reproduce a build](/reproduce). 2. **Changing the policy changes the identity.** If you re-create an enclave with a different allowlist, the new enclave has different PCRs. Clients that pinned the old PCRs will refuse to connect, which is the right behaviour: from their perspective, this is a different deployment. This is the auditor's verification surface. If you want to claim "this enclave only talks to `api.openai.com:443`," you don't ask anyone to trust you: they reproduce the build and read the file. ## Recipes ### Only `api.openai.com` ```bash enclavia enclave create \ --egress-allow api.openai.com:443 \ --egress-resolver 1.1.1.1 ``` ### A customer VPN CIDR ```bash enclavia enclave create \ --egress-allow 10.99.0.0/16:443 ``` No resolver needed; CIDR matches happen on IP literals. ### A larger policy as a file ```bash cat > egress.json <<'EOF' { "version": 1, "resolvers": ["1.1.1.1", "8.8.8.8"], "egress": [ { "host": "api.openai.com", "port": 443, "protocol": "tcp" }, { "host": "api.anthropic.com", "port": 443, "protocol": "tcp" }, { "host": "10.0.0.0/8", "port": 443, "protocol": "tcp" } ] } EOF enclavia enclave create --egress-config ./egress.json ``` ### Try it locally The [`enclavia-samples/egress`](https://github.com/EnclaviaIO/enclavia-samples/tree/main/egress) sample exercises the full path end to end: a tiny service that opens an outbound HTTPS connection through the allowlist and reports the result. A permitted destination returns a real response; a non-permitted destination fails at the in-enclave filter, before any packet leaves the host. Useful as a smoke test for a fresh allowlist. See [Sample apps](/samples) for the full list and the general "clone, build, push" pattern. ## Limitations * **TCP only.** The schema reserves `"udp"` as a value but validation actively rejects it; declaring a UDP entry today fails at create time with a clear error. UDP support is tracked separately and will land without a schema change. Workloads that need DNS should rely on the in-enclave `unbound` for resolution and stick to TCP for everything else. * **IPv6 is always denied.** Every layer rejects v6: schema, validator, daemon. If your upstream is v6-only, it's currently out of reach. * **Hostnames trust the configured resolvers.** Hostname matching dereferences names through your declared `resolvers` (validated by DNSSEC inside `unbound`). A hostile resolver that returns valid DNSSEC for a domain it controls can steer the workload at IPs it shouldn't reach. Stick to resolvers you trust, or use IP/CIDR entries when you need stronger guarantees. --- --- url: /proxy.md --- # Hosted HTTPS proxy The public beta exposes every running enclave at a stable HTTPS URL that speaks plain HTTP and WebSocket. Use it when you want to reach an enclave from a tool that doesn't embed the `enclavia` client SDK: `curl`, a browser fetch, a server-side process in any language, a CDN, a webhook receiver. For a trustless connection that performs attestation client-side, see [Connect from a client](/connect). ## URL shape ``` https://.enclaves.beta.enclavia.io/proxy/ ``` The leftmost subdomain is the enclave's UUID (the one shown by `enclavia enclave status`). The `/proxy/` prefix is stripped before the request reaches your workload, so an endpoint your container exposes as `GET /health` is reachable at `/proxy/health`. ## What goes through Request method, path (after the `/proxy/` strip), headers, and body are forwarded unchanged. Response status, headers, and body come back unchanged. WebSocket upgrades work; idle connections hold for up to one hour. The proxy adds three headers to every response: ``` X-Enclavia-PCR0: X-Enclavia-PCR1: X-Enclavia-PCR2: ``` These are the PCRs the proxy verified during the attestation handshake. They match the values in `enclavia enclave status`. ## Trust model The hosted path terminates TLS at Enclavia's edge, runs the Noise handshake and attestation check on your behalf, and then forwards plaintext into the encrypted tunnel. Concretely, you trust Enclavia to: * Verify the COSE\_Sign1 envelope on the AWS Nitro attestation document. * Check the document against the PCRs registered for your enclave when it was launched. * Refuse to forward traffic if either check fails. That trust is bounded: Enclavia cannot inject traffic the enclave will accept as authenticated by some other party, because the enclave only ever sees the encrypted channel. But Enclavia can see your plaintext requests and responses on the way through. If that trust boundary doesn't fit your threat model, two trustless options exist: * **Direct SDK connection** ([Connect from a client](/connect)). Embed the `enclavia` client, pin the PCRs yourself, and the attestation check runs in your process. Enclavia sees only the encrypted WebSocket bytes. * **Self-hosted proxy** ([Self-host the proxy](/self-host-proxy)). Run the same `pingora-enclavia` binary in front of an enclave you trust, in your own environment. Same hosted shape, same headers, your PCR allowlist. ## Worked example: HTTP ```bash ENCLAVE_ID=... # from `enclavia enclave status` curl -i https://$ENCLAVE_ID.enclaves.beta.enclavia.io/proxy/health ``` The response carries your workload's body and the three `X-Enclavia-PCR*` headers. A `404` with `X-Enclavia-Tunnel-Error: config_not_found` means the enclave isn't currently registered (likely stopped or destroyed); a `502` with `X-Enclavia-Tunnel-Error: tunnel_dial` means the attestation or handshake failed. ## Worked example: WebSocket ```bash websocat wss://$ENCLAVE_ID.enclaves.beta.enclavia.io/proxy/ws ``` The proxy negotiates the upgrade with your workload and then byte-pumps the WebSocket frames in both directions for as long as both sides keep the connection open, up to the one-hour idle timeout. ## When to use which path * Reaching the enclave from a script, `curl`, a webhook, or a browser `fetch`: **hosted path**. * Building an end-user app where the user's device should be the attestor: **[direct SDK](/connect)**. * Running the proxy yourself for compliance or trust reasons: **[self-host](/self-host-proxy)**. --- --- url: /self-host-proxy.md --- # Self-host the proxy [`pingora-enclavia`](https://github.com/EnclaviaIO/pingora-enclavia) is the Pingora-based attested proxy behind the [hosted `/proxy/*` path](/proxy). It's a small Rust service that takes inbound HTTP/WebSocket, dials the WebSocket endpoint of a configured enclave, runs a Noise handshake and AWS Nitro attestation check, then byte-pumps the request through the encrypted tunnel. Self-host it when you want the attestation check to happen in infrastructure you control rather than at Enclavia's edge: air-gapped deployments, compliance regimes that forbid third-party termination, or running in front of an enclave whose PCRs only you trust. ## Role of the proxy ``` client (HTTP / WS) ↓ TLS (your front-end: nginx, Caddy, ...) your front-end ↓ plain HTTP pingora-enclavia ↓ WSS → Noise NN → Nitro attestation remote enclave ↓ your workload ``` `pingora-enclavia` doesn't terminate TLS. It expects a front-end to do that and forward plaintext on a loopback port. The trust boundary the proxy enforces is the attestation check: a configured set of PCRs (from `enclavia enclave status`) per enclave UUID, refreshed from disk via inotify so a controller can add or remove targets without restarting. ## Install: NixOS module (preferred) ```nix { inputs.pingora-enclavia.url = "github:EnclaviaIO/pingora-enclavia"; outputs = { self, nixpkgs, pingora-enclavia, ... }: { nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { modules = [ pingora-enclavia.nixosModules.default ({ ... }: { services.pingora-enclavia = { enable = true; configDir = "/var/lib/pingora-enclavia/targets"; listen = "127.0.0.1:6188"; }; }) ]; }; }; } ``` The module creates a system user/group (`pingora-enclavia`), runs the binary as a hardened systemd unit, and creates `configDir` mode `2770`. Any other service that needs to write target JSON files should join the `services.pingora-enclavia.targetsGroup` group. You still need to put a front-end in front of the listener. The nginx snippet, including the `map` for the WebSocket upgrade header (without which WebSocket frames don't flow): ```nginx map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 443 ssl http2; server_name ~^(?.+)\.enclaves\.example\.com$; location /proxy/ { rewrite ^/proxy/(.*) /$1 break; proxy_pass http://127.0.0.1:6188; proxy_set_header Host $host; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } } ``` The `map` block belongs in the `http` context, not inside `server`. Skipping it is the most common WebSocket-doesn't-work bug. ## Install: Docker ```bash docker run \ -v $(pwd)/targets:/etc/pingora-enclavia/targets \ -p 6188:6188 \ enclaviaio/pingora-enclavia:0.1.0 ``` The image reads target files from `/etc/pingora-enclavia/targets` and listens on `0.0.0.0:6188`. ## Install: from source ```bash git clone https://github.com/EnclaviaIO/pingora-enclavia cd pingora-enclavia cargo build --release --bin pingora-enclavia ./target/release/pingora-enclavia \ --config-dir ./targets \ --listen 127.0.0.1:6188 ``` ## Target config file One file per enclave you want to proxy, keyed by UUID: ```json { "enclave_id": "", "endpoint": "wss://.enclaves.beta.enclavia.io", "pcrs": { "pcr0": "", "pcr1": "", "pcr2": "" }, "debug_mode": false } ``` Filename is `.json`. The proxy watches the directory with inotify and reloads on create/modify/delete without restart. `debug_mode: true` accepts the stub attestation produced by debug-mode enclaves; never set it for production. The proxy dispatches on the leftmost label of the inbound `Host` header. Send `Host: .enclaves.example.com` (your domain, not Enclavia's) and the matching target file is picked up. ## Operational notes * **Logs**: JSON-formatted via `tracing`, one info line per request, error lines carry a `failure_kind` field (`config_not_found`, `bad_config`, `tunnel_dial`). Point your log shipper at journald. * **Health endpoint**: `GET /healthz` returns `200 ok` when the config dir is readable, `200 degraded` otherwise. Use it for upstream checks. * **Graceful shutdown**: SIGTERM triggers Pingora's drain (default 5 s). In-flight requests finish; new connections are refused. * **Timeouts**: `tunnelTimeoutSecs` (default 10 s) bounds the WSS+Noise+attestation handshake; `requestTimeoutSecs` (default 30 s) is the per-request upstream read/write window. * **Failure surface**: `X-Enclavia-Tunnel-Error: ` is set on every error response. The PCR mismatch, Noise handshake failure, and attestation parse cases currently collapse into `tunnel_dial`; the failing leg lands in the error log line. * **Pooling**: every inbound request opens a fresh attested tunnel. Tunnel pooling isn't in the beta cut. ## See also * [Hosted `/proxy/*` path](/proxy): the same proxy, run for you. * [Connect from a client](/connect): the trustless alternative when the attestation check should run on the end user's device. * The [`pingora-enclavia`](https://github.com/EnclaviaIO/pingora-enclavia) source: licensed Apache-2.0 OR MIT. --- --- url: /secrets.md --- # Per-enclave secrets Enclavia lets you attach small, named environment-variable secrets to an enclave. The backend stores them encrypted at rest, the values never appear in any API response, and they only ever leave the backend over an authenticated single-shot vsock channel into the enclave at boot. Inside the enclave they land in the workload's `process.env` before the container's entrypoint runs. Plaintext is never written to disk inside the EIF and never logged. ## Why use them Anything sensitive your workload needs (database URLs, API keys, signing keys, OAuth client secrets) is a problem if you bake it into the Docker image. The image is publishable: anyone who can pull it from the registry can extract baked-in values. Even non-sensitive per-deployment configuration (staging vs production endpoints, per-customer keys) is awkward to hardcode, because every change becomes a new image build with a new content hash. Secrets are injected at boot through the in-enclave init path, never written into the image, and never measured into PCRs. That gives you two things: 1. **Sensitive material stays out of the registry.** API keys, signing keys, database URLs are never visible to anyone who can pull the image, because they aren't there. 2. **You can reconfigure without rebuilding.** Rotating, adding, or removing a secret is an enclave restart. The EIF is unchanged, so the PCRs are unchanged, so any client pinned to this enclave's PCRs keeps working through the rotation. (PCRs are always per-enclave, by design: the enclave's UUID is stamped into the rootfs at build time, so even two enclaves built from the same image have different PCR2 values. See [Connect from a client](/connect#get-the-pcrs-you-need-to-pin).) ## Trade-offs * **Changes take effect on the next start.** A `set`, rotate, or delete is recorded in the backend immediately, but the running enclave keeps seeing the previous values until you restart it (or it stops and starts again on its own). * **No per-secret access policy.** Anyone who owns the enclave can rotate or delete any of its secrets. There are no per-secret IAM grants, no audit log of reads (there are no reads). * **Names, not files.** Each secret becomes one environment variable. There is no filesystem-mounted secret store; if your workload needs a file, write the env var out yourself inside the container entrypoint. ## CLI usage Three subcommands: `set`, `list`, `delete`. All three take an enclave id (full UUID or any unique prefix that resolves to one of your enclaves). ### Set The simplest form takes one or more `NAME=value` pairs: ```bash enclavia secret set 1d2c3b4a DATABASE_URL=postgres://... STRIPE_KEY=sk_test_abc ``` Each name is validated client-side and re-validated by the backend before insertion. A name that fails validation aborts the whole call before any request goes out. For values you do not want to appear in shell history, pass them via stdin or a file. Both forms require `--name` and only set a single secret per call: ```bash # From stdin (no trailing newline is stored; pipe-friendly). echo -n 'sk_live_...' | enclavia secret set 1d2c3b4a --from-stdin --name STRIPE_KEY # From a file (read verbatim, must be valid UTF-8). enclavia secret set 1d2c3b4a --from-file ./stripe.key --name STRIPE_KEY ``` The stdin and file forms both treat the value as a UTF-8 string. A binary blob piped into `--from-stdin` will be rejected with a UTF-8 decode error rather than silently corrupted. If you need to store binary data, base64 it on your side first. After a successful `set` against a running enclave the CLI reminds you that the new values are pending: ``` 2 changes pending. Run `enclavia enclave restart 1d2c3b4a` to apply. ``` If the enclave is `stopped`, the message instead notes that the new values will land on the next start. ### List ```bash enclavia secret list 1d2c3b4a ``` Output: ``` NAME LAST UPDATED PENDING -------------------------------------------------------------------------------- DATABASE_URL 2026-06-04T10:22:11Z no STRIPE_KEY 2026-06-04T12:05:48Z yes ``` `PENDING: yes` means the secret was written or rotated after the most recent successful enclave start, so the running workload is still seeing the previous value (or no value at all, if the secret is brand new). For an enclave that has never started, every secret is reported as pending. Values are never returned by the backend, so they are never printed by `list`. You can rotate or delete a secret you can no longer read, but you cannot read it back. ### Delete ```bash enclavia secret delete 1d2c3b4a STRIPE_KEY ``` Multiple names can be passed in one call. Each name prompts for confirmation unless you pass `--yes`: ```bash enclavia secret delete 1d2c3b4a --yes STRIPE_KEY DATABASE_URL ``` A delete is treated like any other change: it lands on the next enclave start. The running workload keeps the old environment variable until then. ### Restart to apply ```bash enclavia enclave restart 1d2c3b4a ``` Server-side stop + start. The next boot reads a fresh snapshot of the secrets table. ## Dashboard usage On `beta.enclavia.io` the enclave detail page has a Secrets panel that mirrors the CLI surface: list, add, rotate, delete, with a per-row "pending" pill on any secret that has been written or rotated since the last successful start. Adding or rotating a secret takes a name and a value (with a show/hide toggle); the value is sent straight to the backend and is not returned on subsequent loads, so the same row offers rotate and delete but no read-back. When at least one row is pending on a running enclave, a banner appears at the top of the panel with a "Restart now" button that performs the same server-side stop + start the CLI's `enclavia enclave restart` does. The panel also enforces the per-enclave cap by hiding the "add secret" button once you hit 32 rows. ## Naming rules Names must match the regex: ``` ^[A-Z_][A-Z0-9_]*$ ``` Concretely: uppercase ASCII letters, digits, and underscores; the first character is a letter or underscore (not a digit); the whole name is at most **64 characters**. A small set of names is rejected because the runtime sets them inside the OCI bundle and a user-supplied value would be shadowed: ``` PATH, HOME, HOSTNAME, PWD, OLDPWD, TERM, SHLVL, _ ``` Names starting with two underscores (`__FOO`) are reserved for future internal use and also rejected. ## Limits | Limit | Value | |-------|-------| | Secrets per enclave | 32 | | Bytes per value | 4 KiB | | Total bytes per enclave (sum of stored ciphertexts) | 16 KiB | | Name length | 64 characters | These caps are enforced server-side. A `set` or rotate that would push you past any of them fails with a pointed error before encryption runs. ## When changes take effect Every successful enclave start re-reads the secrets table and snapshots it into the launching enclave. That means: * **Running enclave.** A `set`, rotate, or delete is queued (the `pending` flag flips to `yes`) and lands on the next restart. Restart with `enclavia enclave restart ` or the "Restart now" button in the dashboard. * **Stopped enclave.** Changes land on the next `enclavia enclave start `. No extra step. * **Brand new enclave that has not finished its first build.** Changes you make during the `building` window land on the first boot. (A recent first-boot fix changed this; an earlier version of the platform silently discarded `secret set` calls made before the first successful start, which meant the first boot came up with an empty environment. If you hit that, the fix is in production and re-running `secret set` now persists across the auto-start.) There is no in-band signal to the running workload that secrets are stale. If you need that, surface the `pending` flag from `secret list` in your own deployment pipeline and trigger the restart from there. ## How it works A short note for users who want to know what they are trusting. Each secret value is encrypted at rest in the backend with an authenticated cipher; only ciphertext is persisted, and the plaintext is dropped from memory as soon as the row is written. At enclave start the backend: 1. Reads the current rows for that enclave, decrypts them in memory, and serializes the result as a CBOR `map` keyed by secret name. 2. Hands the serialized payload to a host-side single-shot daemon that serves it on a per-enclave vsock port. 3. The in-enclave init binary (`enclavia-secrets-init`) opens that vsock port, reads the CBOR map, and writes each entry into the OCI bundle's `process.env` before the container starts. Plaintext is never written to a file inside the EIF, never copied into an env-file on disk, and never appears in container logs. The vsock channel is single-shot: once the in-enclave init has read the payload, the host daemon exits. Because the secrets ship at start time, they are not part of the EIF's PCRs. The enclave's identity (PCR0/1/2) is the same regardless of which secret values it was started with. If you need a value to be part of the attested identity instead of a runtime secret, bake it into your container image so it ends up under PCR2. ## Recipes ### Bulk-set from a `.env`-style file The CLI does not parse `.env` files directly, but a one-liner gets you there: ```bash enclavia secret set 1d2c3b4a $(grep -v '^#' .env | xargs) ``` (Names must already conform to the naming rules; the call aborts on the first invalid entry.) ### Rotate a single value without leaking it to history ```bash read -s -p 'new value: ' NEW; echo echo -n "$NEW" | enclavia secret set 1d2c3b4a --from-stdin --name STRIPE_KEY unset NEW ``` ### Inspect what is pending before restarting ```bash enclavia secret list 1d2c3b4a | awk '$3 == "yes"' enclavia enclave restart 1d2c3b4a ``` --- --- url: /samples.md --- # Sample apps The fastest way to feel what Enclavia does is to run a sample. Every sample is a self-contained Docker image with a short README that walks you through `create` → `push` → connect. Pick one, follow its README, and you'll have something running inside an attested enclave in a few minutes. ## Where they live All samples live in one public repo: **[github.com/EnclaviaIO/enclavia-samples](https://github.com/EnclaviaIO/enclavia-samples)** Each top-level directory is a standalone sample. Clone the repo, change into the directory of the sample you want to try, and follow that README. ```bash git clone https://github.com/EnclaviaIO/enclavia-samples cd enclavia-samples/ # follow the README in that directory ``` ## The general shape Every sample expects the same basic prerequisites: the [`enclavia` CLI](/install) is installed and authenticated, Docker is running, and you've completed [`enclavia auth login`](/auth). Most samples then walk through the same three phases: 1. **Build the image locally** with `docker build`. 2. **Create the enclave** with `enclavia enclave create` (the sample's README spells out the flags — usually `--container-port` and sometimes an [egress allowlist](/egress)). 3. **Push the image** with `enclavia push `. The push flips the enclave to `building`; once it's `running`, the sample shows how to connect to it (either from the [client library](/connect) or, where relevant, the dashboard). Steps 2 and 3 collapse into one when you're at a terminal: [`enclavia deploy [create flags]`](/deploy) creates, pushes, and follows the build until the enclave is running. If you have the [MCP connector](/mcp) wired up, step 2 (and any inspection along the way) can be driven from your AI agent in natural language. Step 3 still runs on your laptop because pushing needs your local Docker daemon. ## Just the management surface, no Docker If you don't want to install Docker yet and you just want to see Enclavia respond, the [MCP connector](/mcp) alone is enough to: * Create an enclave (it will sit in `waiting_for_image`). * List your enclaves, inspect status, read build logs. * Stop / destroy enclaves you created earlier. That's not the full loop — until you `enclavia push` an image the enclave never reaches `running` — but it's enough to confirm the connector is wired correctly against your account before you commit to a local install. ## Use a sample as a starting point Each sample is intentionally small. Once one is running, copy its directory into your own project, swap the app for your own code, and you have a known-good `enclave create` invocation + push flow to build on top of. The `egress` sample in particular is useful as a template for any workload that needs outbound traffic — the [Outbound egress allowlist](/egress) page explains the policy semantics it exercises. ## Want to contribute a sample? PRs are welcome at [EnclaviaIO/enclavia-samples](https://github.com/EnclaviaIO/enclavia-samples). The bar is roughly: a small Dockerfile, a 30-line README, and instructions that work against `api.beta.enclavia.io` without any private dependencies. --- --- url: /upgrades.md --- # Staged deployments and the upgrade chain Enclavia treats every version transition as a deliberate, auditable event. The first deploy (the **genesis**) launches immediately. Every subsequent push to an upgradable enclave is staged: the new image is built but not launched. An explicit confirm step is required to schedule the swap, and the running enclave keeps serving until the scheduled time arrives. ## Upgradable vs non-upgradable enclaves At create time you choose whether an enclave can ever be upgraded in place: ```bash # Upgradable: future pushes are staged, not auto-deployed. enclavia enclave create --upgradable # Non-upgradable (default): every push after the first is rejected. enclavia enclave create ``` The choice is permanent for the enclave's lifetime. Existing enclaves created before this feature shipped are non-upgradable. ::: warning Decide before you create: iterating wants `--upgradable` Because the choice is immutable, it is worth thinking about at `create` time rather than discovering it the hard way. On a **non-upgradable** enclave (the default), shipping any new image, even a one-line bug fix, means `destroy`, `create` a fresh enclave, `push`, and re-pin the new PCRs in every client. That is the right lockdown for a settled production deployment, but painful while you are still iterating. If you expect to push more than once, create with `--upgradable` and use [`--immediate`](#confirming-an-upgrade) during development to cut over in seconds. Tighten later (a new non-upgradable enclave, or a [minimum upgrade delay](#minimum-upgrade-delay)) once the code has settled. ::: **Upgradable enclaves** get an ECDSA P-256 control keypair. The public key is baked into every EIF built for the enclave, so the running version can verify that any upgrade or revocation command came from an authorized source. Who holds the private key depends on the enclave's custody mode: * **Managed (default):** the private key stays in the backend, encrypted at rest. The backend signs confirm and revoke commands itself, and you approve them from the web dashboard. * **Self-hosted:** you hold the private key on a YubiKey, the backend stores only the public half, and confirm and revoke are authorized by a signature from your hardware via the CLI. The rest of this page describes the managed flow. For the self-hosted flow (including how to generate a key, create a self-hosted enclave, and the two-phase CLI confirm), see [Control-key custody](/custody). **Non-upgradable enclaves** have no control keypair and no signed-upgrade path. Pushing a second time to a non-upgradable enclave produces an error: ``` Error: this enclave is non-upgradable, create a new one ``` ## Genesis: the first push The first push to any enclave triggers an immediate build and launch, regardless of whether the enclave is upgradable. ```bash enclavia enclave create --upgradable --name my-service # Enclave created: # ID: 1d2c3b4a-5e6f-7a8b-9c0d-1e2f3a4b5c6d # Status: waiting_for_image enclavia push myapp:v1 1d2c3b4a # Tagging myapp:v1 -> registry.beta.enclavia.io/alice/1d2c3b4a-...:latest # Pushing registry.beta.enclavia.io/alice/1d2c3b4a-...:latest # ... # Notified backend; 1 build now starting: # enclavia enclave status 1d2c3b4a ``` Once the enclave boots it records a **boot attestation** as the first entry in its upgrade chain. ## Staging a new version Pushing again to a running upgradable enclave stages the new image instead of deploying it: ```bash enclavia push myapp:v2 1d2c3b4a # Tagging myapp:v2 -> registry.beta.enclavia.io/alice/1d2c3b4a-...:latest # Pushing registry.beta.enclavia.io/alice/1d2c3b4a-...:latest # ... # Staged upgrade a3b4c5d6-... for enclave 1d2c3b4a-... # Confirm with: enclavia upgrade confirm 1d2c3b4a a3b4c5d6 ``` The running enclave is unaffected. The new EIF is built in the background; once the build finishes the staged upgrade moves from `building` to `staged`. To see all staged upgrades for an enclave: ```bash enclavia upgrade list 1d2c3b4a ``` Output: ``` UPGRADE ID STATUS IMAGE DIGEST VALID FROM CREATED ---------------------------------------------------------------------------------------------------------------------------------------------------------------- a3b4c5d6-... staged 1d2c3b4a-...:latest sha256:9c1f4b - 2026-07-01 10:00 UTC ``` ## Confirming an upgrade Confirming schedules the swap and kicks off the attestation handshake with the running enclave. The running version keeps serving until `valid_from`. ```bash # Default: swap in 7 days. enclavia upgrade confirm 1d2c3b4a a3b4c5d6 # Pick a specific time (RFC 3339). enclavia upgrade confirm 1d2c3b4a a3b4c5d6 --at 2026-07-08T12:00:00Z # Take effect immediately. enclavia upgrade confirm 1d2c3b4a a3b4c5d6 --immediate ``` If the enclave was created with a [minimum upgrade delay](#minimum-upgrade-delay), `--immediate` (and any `--at` earlier than now plus the delay) is rejected: first by the backend with a clear error, and authoritatively by the running enclave itself. Successful output: ``` Upgrade a3b4c5d6-... confirmed. Status: confirmed Valid from: 2026-07-08 12:00:00 UTC The enclave will swap to the new version automatically at that time. ``` What happens at confirm time (managed custody): 1. The backend constructs an upgrade-auth payload (`from_pcrs`, `to_pcrs`, `image_digest`, `valid_from`) and signs it with the enclave's control private key. 2. The signed command is dispatched to the running enclave over the Noise channel. The enclave verifies the signature against its baked-in control public key. 3. The running enclave emits an **upgrade attestation** (a Nitro-signed document) and the backend appends an `upgrade` entry to the chain. 4. At `valid_from` the launcher tears down the old version and boots the new EIF. On its first heartbeat the new enclave produces a **boot attestation**, recorded as a `boot` entry in the chain. A successful upgrade therefore produces two chain entries: the old enclave's `upgrade` link at confirm time, and the new enclave's `boot` link at cutover. On a **self-hosted** enclave the same `enclavia upgrade confirm` command runs a two-phase flow instead: the CLI fetches the exact payload plus a live nonce from the backend, signs it on your YubiKey (expect a PIN prompt and two touches), and submits the signed command. Everything downstream (dispatch, verification, chain link, cutover) is identical. See [Control-key custody](/custody#upgrading-a-self-hosted-enclave). ## Minimum upgrade delay By default the activation delay is the operator's choice: confirm defaults to now plus 7 days, but `--immediate` can activate an upgrade within seconds. If your users need a *guaranteed* window to audit a pending upgrade before it activates, create the enclave with a minimum upgrade delay: ```bash enclavia enclave create --upgradable --min-upgrade-delay 48h ``` The value (`30m`, `48h`, `7d`, or a bare number of seconds; maximum 90 days) is baked into every image built for the enclave, so it is covered by the enclave's PCR measurements and cannot be changed after create, not even by pushing a new version: upgrade builds carry the same value, so an upgrade can never shed the floor. Enforcement is done by the running enclave itself, not by backend policy. At confirm time the enclave checks the signed upgrade command's `valid_from` against its own clock and refuses anything earlier than now plus the configured delay. That makes the revocation window a verifiable property of the enclave's identity: anyone who reproduces the image or checks the PCRs knows that the code inside cannot be swapped faster than the declared delay, even by the legitimate control-key holder. A compromised control key cannot fast-track a malicious image past watching verifiers. Practical consequences: * `enclavia upgrade confirm --immediate` fails on these enclaves, and `--at` must be at least the delay in the future. The web dashboard disables the immediate option and floors the schedule picker. * With no explicit time, confirm schedules at now plus 7 days or now plus the delay, whichever is later. * **Revoke is unaffected.** The window exists precisely so that revocation and third-party auditing have guaranteed time before a confirmed upgrade activates. * The chain records `valid_from` and `issued_at` on every `upgrade` link, so verifiers can also check historically that the policy was respected. One trust caveat, stated plainly: the enclave checks `valid_from` against its own clock, and the guest clock is influenced by the host. A host that warps the clock forward can shrink the effective delay. The delay is therefore a strong guarantee against a compromised or coerced key holder, and a weaker one against a malicious hosting substrate (which the attestation model already treats as the adversary for confidentiality, not availability). ## Revoking an upgrade Between confirm and `valid_from` you can cancel the scheduled swap: ```bash enclavia upgrade revoke 1d2c3b4a a3b4c5d6 ``` Output: ``` Upgrade a3b4c5d6-... revoked. Status: revoked The upgrade has been cancelled; the enclave keeps running the current version. ``` On a managed enclave the backend signs a revocation command and dispatches it to the running enclave. The enclave acknowledges and emits a **revocation attestation**, recorded as a `revocation` entry in the chain. The scheduled cutover is cancelled and the running version continues uninterrupted. On a self-hosted enclave, `enclavia upgrade revoke` signs the revocation on your YubiKey through the same two-phase flow as confirm. See [Control-key custody](/custody). ## Storage-enabled enclaves For enclaves with a persistent encrypted volume (created with `--storage-size-bytes`), the LUKS volume is re-keyed to the new version automatically as part of confirm. The new enclave opens the volume on its first boot using its own KMS key, and the data is available exactly as it was in the previous version. No user action is needed; the re-keying happens on the running enclave at confirm time and is covered by the `upgrade` chain attestation. ## The upgrade chain Every transition is recorded as a public, append-only, hardware-attested chain. You can inspect it at any time: ```bash enclavia upgrade chain 1d2c3b4a ``` Example output after a full upgrade cycle: ``` Chain for enclave 1d2c3b4a-... (3 links) #1 boot 2026-07-01 10:05:23 UTC [verified] image: sha256:9c1f4b2d... booted_at: 2026-07-01 10:05:23 UTC PCR0: 4f8c2a1b... PCR1: 7e3d9c0a... PCR2: 6b5a4938... attestation: 1204 bytes #2 upgrade 2026-07-01 14:22:07 UTC [verified] target: sha256:b3e7a19c... valid_from: 2026-07-08 12:00:00 UTC issued_at: 2026-07-01 14:22:07 UTC to.PCR0: 8a3e5d92... to.PCR1: 1c7b4f03... to.PCR2: 3d9e2a71... attestation: 1204 bytes #3 boot 2026-07-08 12:00:45 UTC [verified] image: sha256:b3e7a19c... booted_at: 2026-07-08 12:00:45 UTC PCR0: 8a3e5d92... PCR1: 1c7b4f03... PCR2: 3d9e2a71... attestation: 1204 bytes Chain is valid. 3 links, all verified locally. ``` After a revocation the chain looks like: ``` #1 boot ... [verified] #2 upgrade ... [verified] #3 revocation 2026-07-04 08:11:02 UTC [verified] revokes: a3b4c5d6-... issued_at: 2026-07-04 08:11:02 UTC attestation: 1204 bytes ``` The CLI re-validates each link locally when you run `upgrade chain`. The `[verified]` badge reflects the client's own check, not a server claim. ## Staged upgrade statuses | Status | Meaning | |--------|---------| | `building` | The new EIF is being built. PCRs and image digest are not yet available. | | `staged` | Build complete; awaiting operator confirmation. The running enclave has not been notified. | | `confirmed` | `valid_from` is set; the backend has dispatched the signed upgrade command to the running enclave and recorded the `upgrade` chain link. The swap fires automatically at `valid_from`. | | `promoted` | The new enclave has started successfully. The upgrade is complete. | | `revoked` | The upgrade was cancelled before `valid_from`. The running enclave's LUKS state is rolled back (if applicable) and a `revocation` chain link is recorded. | | `failed` | The build or chain-link submission failed. Check `upgrade list` for an error message. | | `expired` | Staged but never confirmed within the retention window; garbage-collected. | ## How the chain is verified Each chain link is a Nitro attestation document whose `user_data` field is `sha256(payload)`. To verify a link independently: 1. Verify the attestation's signature against Amazon's NSM root certificate. 2. Compute `sha256(payload)` and compare it to `attestation.user_data`. 3. For each `upgrade` link, confirm that `payload.from_pcrs` matches the previous active `boot`'s PCRs. 4. For each `boot` following an `upgrade`, confirm the `boot`'s PCRs match the `upgrade`'s `to_pcrs`. 5. A `revocation` cancels its referenced `upgrade`; that upgrade entry does not count as a forward step when computing the chain head. No control-key signature appears in the chain itself. The hardware attestation is the only chain-level cryptographic primitive. The `upgrade chain` command performs all of these checks locally and labels each link `[verified]` or `[REJECTED]`. For a detailed walkthrough of the attestation verification model, see [Reproduce a build](/reproduce). --- --- url: /custody.md --- # Control-key custody: managed vs self-hosted Every [upgradable enclave](/upgrades) has an ECDSA P-256 control keypair. The public half is baked into every EIF built for the enclave, so the running version can verify that any upgrade or revocation command came from an authorized source. Custody is about who holds the private half, and therefore who can authorize those commands. There are two modes, chosen once at create time and immutable for the enclave's lifetime (the public key is measured into the EIF's PCRs, so it cannot change without rebuilding the enclave's identity). ## The two modes **Managed (default).** The backend generates the keypair and keeps the private half encrypted at rest. When you confirm or revoke an upgrade, the backend decrypts the scalar and signs the command itself. You approve upgrades with one click from the web dashboard. This is the zero-setup path: nothing to install, no hardware, no key to lose. **Self-hosted (YubiKey).** You generate the key on a YubiKey (PIV slot, on-device, non-extractable). The backend only ever stores the public key. It has no way to sign anything, so upgrades and revocations can only be authorized by a signature produced on your hardware, from a machine you control. The dashboard shows copyable CLI commands instead of action buttons, because the backend cannot perform the action for you. ### The trust tradeoff, plainly * With **managed** keys you trust Enclavia not to push a malicious upgrade. The backend can technically sign an upgrade command on its own. The [upgrade chain](/upgrades#the-upgrade-chain) still records every transition as a public, hardware-attested, append-only log, so a silent swap is detectable after the fact, but the key material to authorize it lives on our servers. * With **self-hosted** keys Enclavia cannot upgrade or revoke without your signature. The tradeoff is that responsibility for the key is entirely yours: if you lose the YubiKey, the enclave is frozen on its current version forever (see [Losing the key](#losing-the-key)). ### Comparison | | Managed (default) | Self-hosted (YubiKey) | |---|---|---| | Who holds the private key | Enclavia backend (encrypted at rest) | You, on a YubiKey (non-extractable) | | Who can authorize an upgrade or revocation | Enclavia, on your approval | Only a signature from your hardware | | How you approve | One click in the web dashboard | `enclavia upgrade confirm/revoke` from the CLI (PIN + touch) | | Dashboard surface | Confirm / revoke buttons | Copyable CLI commands | | Recovery if the key material is lost | Backend still holds it; nothing to recover | Re-import from the device, or if the device is gone the enclave is frozen | | You must trust | Enclavia not to push a malicious upgrade | Only your own key custody | The split is invisible to the enclave: same public-key slot, same signatures, same verification. Only the location of the private key differs. ## Generating a control key on a YubiKey The key is generated on-device and never leaves the hardware. You need a YubiKey 5 (PIV, ECDSA P-256). ```bash enclavia key generate --yubikey --name deploy-key ``` Full flag set: ```bash enclavia key generate --yubikey \ [--name ] \ [--slot 9c] \ [--touch-policy always|cached|never] \ [--pin-policy once|always|never] \ [--serial ] \ [--yes] ``` | Flag | Default | Purpose | |------|---------|---------| | `--yubikey` | (required) | Generate on a YubiKey. Currently the only self-hosted backend. | | `--name ` | `default` | Name the key is recorded under in the local index; this is what you pass to `enclave create --control-key`. | | `--slot ` | `9c` | PIV slot to generate into (`9a`, `9c`, `9d`, `9e`). `9c` is the Digital Signature slot. | | `--touch-policy ` | `always` | Require a physical touch on every signature (`always`), cache it for 15 seconds (`cached`), or never (`never`). | | `--pin-policy ` | `once` | Require the PIN once per session (`once`), before every signature (`always`), or never (`never`). | | `--serial ` | unset | Disambiguate when several YubiKeys are connected. | | `--yes` | off | Skip the slot-replacement confirmation prompt (for non-interactive use). | ::: warning Generation replaces any key already in the slot PIV key generation into an occupied slot silently overwrites whatever key was there. Before touching the hardware, the CLI prints what it is about to do and waits for you to press Enter (Ctrl-C aborts). If slot `9c` already holds a control key you rely on, generating into it destroys that key. Pass `--yes` only when you are certain the slot is free or expendable. ::: The private key is generated on-device and cannot be extracted. Only the public key is recorded, in a local index at `~/.config/enclavia/keys/index.json`. That file holds public metadata only (name, backend type, device serial, slot, public key, and its fingerprint); it never contains private key material, so it is not itself a secret. List what you have: ```bash enclavia key list ``` Each row shows the name, backend, device, and the public-key fingerprint. ## Creating a self-hosted enclave Register the named local key as the enclave's control key at create time: ```bash enclavia enclave create --image myapp:v1 --upgradable --control-key deploy-key ``` `--control-key ` implies `--upgradable` (a control key only makes sense on an upgradable enclave), so you can omit `--upgradable` if you like. The CLI resolves the key locally before creating anything, so a typo in the key name fails fast rather than creating a managed enclave by accident. The backend stores only the public key and marks the enclave as self-hosted custody; this is immutable for the enclave's lifetime. A plain `--upgradable` with no `--control-key` stays in managed custody. ## Upgrading a self-hosted enclave The staging flow is identical to a [managed upgrade](/upgrades): push a new tag, which stages the upgrade, then confirm it. ```bash # Stage the new version. enclavia push myapp:v2 1d2c3b4a # Confirm it (same flags as managed). enclavia upgrade confirm 1d2c3b4a a3b4c5d6 # default: swap in 7 days enclavia upgrade confirm 1d2c3b4a a3b4c5d6 --at 2026-07-08T12:00:00Z enclavia upgrade confirm 1d2c3b4a a3b4c5d6 --immediate ``` The `--at` / `--immediate` scheduling flags behave exactly as in managed mode. What differs is what happens under the hood: because the backend has no private key, `confirm` runs a two-phase flow. 1. **Prepare.** The CLI asks the backend for the exact bytes to sign: the upgrade payload plus a live nonce fetched from the running enclave over its attested control channel. (The nonce rotates only when the enclave actually processes a control command, so it stays valid across an offline signing round-trip, which is plenty of time for a YubiKey touch.) 2. **Sign.** The CLI signs twice on the YubiKey: once over the payload (the inner signature) and once over the assembled control command (the envelope signature). Expect two touch prompts and one PIN prompt. 3. **Submit.** The CLI sends the signed command back. The backend dispatches it to the running enclave, which verifies both signatures against its baked-in public key and, on success, records the `upgrade` chain link exactly as in managed mode. If the enclave's nonce changed between prepare and submit (a concurrent control command landed in between), the submit returns a `409` and the CLI transparently re-prepares and retries once. Revocation has the same shape: ```bash enclavia upgrade revoke 1d2c3b4a a3b4c5d6 ``` Same two-phase prepare, sign-twice, submit flow, with the same single retry on a nonce conflict. From the web dashboard, a self-hosted enclave's upgrade panel shows these commands as copyable text rather than confirm and revoke buttons, since only you can produce the signature. ## Key recovery ### Rebuilding a lost index The local index at `~/.config/enclavia/keys/index.json` holds only public metadata, so losing it (a wiped laptop, a new machine) does not lose the key: the private key still lives on the YubiKey. Rebuild the index entry by reading the public key back off the device: ```bash enclavia key import --yubikey --name deploy-key [--slot 9c] [--serial ] ``` This reads the public key via PIV GET METADATA (YubiKey firmware 5.2.3 or newer). Nothing is written to the device and no PIN is prompted; it only reads the public half back and records it exactly as `generate` would have. The recovered entry is byte-identical to the original, so the enclave still recognizes it. ### Losing the key ::: danger If the YubiKey is lost, the enclave is frozen forever Self-hosted custody means Enclavia never holds your control key. If the YubiKey itself is lost, destroyed, or the on-device key is regenerated (which overwrites the old private key), then **enclaves bound to that key can never be upgraded or revoked again**. Enclavia cannot help you: there is no backend copy, no reset, no escape hatch. That is the entire point of self-hosted custody. The enclave keeps running its current version indefinitely. What you lose is the ability to change versions: you cannot confirm a staged upgrade and you cannot revoke one. To move to a new version you would have to create a fresh enclave (with a new identity and new PCRs) and migrate to it. For v1 there is no support for multiple registered keys or backup keys per enclave. If frozen-on-loss is a risk you cannot accept, use managed custody instead. ::: --- --- url: /reproduce.md --- # Reproduce an enclave's build `enclavia reproduce ` rebuilds an enclave's EIF on your machine and checks that the PCRs of your local build match the ones the backend recorded for the original build. This is the user-facing half of Enclavia's reproducibility story. PCRs are deterministic measurements of the enclave's kernel + initramfs + rootfs — same inputs in, same PCRs out. If your local rebuild produces a different PCR than the backend recorded, the backend's claim about what code is running in the enclave is suspect, and you should refuse to trust it. ## Trust model You don't have to trust the host to tell you the truth about which code it booted — instead you pin the PCRs you expect (`Pcrs { pcr0, pcr1, pcr2 }` in the [client library](/connect)) and the Noise attestation flow fails closed if the running enclave's measurements don't match. `enclavia reproduce` answers the prior question: *what should those PCRs be?* It pulls the image the backend pinned by digest (so a later push to the same tag can't drift the build), runs the same `builder` binary the backend uses, and compares its output to the row the backend wrote at build time. Anyone can do this for `public` enclaves; owners can do it for their `private` ones (registry-enforced). The recorded inputs include the [egress allowlist](/egress): the JSON document baked into the EIF at `/etc/enclavia/egress.json`. `enclavia reproduce` rebakes the same document into the local build, so PCR2 matches. An auditor reading the reproduce output can see exactly which outbound destinations the running enclave can reach. ## Run it ``` $ enclavia reproduce 2f7e1a3c Enclave: 2f7e1a3c-8b9d-4ec2-9a01-77c5e0a4d8b1 Image digest: sha256:9c1f4b2d6a7e8c0a3f5d2b1c8e7a6f4d3c2b1a0e9d8c7b6a5f4e3d2c1b0a9f8e Recorded revs: builder e3a91bd0f4c5d6e7a8b9c0d1e2f3a4b5c6d7e8f9 crates a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 (the original build was pinned to these revs; if the local PCRs diverge, re-run with a builder checked out to those revisions before reporting a failure.) Running local builder: "builder" output: /tmp/enclavia-reproduce-2f7e1a3c-8b9d-4ec2-9a01-77c5e0a4d8b1 ... ✓ Reproducible — local PCRs match the recorded build. PCR0: 4f8c2a1b... PCR1: 7e3d9c0a... PCR2: 6b5a4938... ``` On success the command exits `0`. On a PCR mismatch it prints each diverging slot and exits non-zero: ``` ✗ NOT reproducible — 1 PCR(s) diverged: PCR0 expected: 4f8c2a1b... actual: 8a3e5d92... ``` The enclave id can be a unique prefix (same rule as `enclavia push`); a full UUID works without authentication for public enclaves. ## Pinning your local builder to the recorded revs PCRs are reproducible only if **all** inputs match, including the `builder` binary itself and the `enclavia` workspace (the in-enclave crates) it consumes. The backend records the git revs of both at build time and `enclavia reproduce` prints them as `Recorded revs:`. If your local PCRs diverge, point your local `builder` at those revs before reporting a bug: ```sh # In your local checkout of the builder repo git checkout e3a91bd0f4c5d6e7a8b9c0d1e2f3a4b5c6d7e8f9 # Build the binary (see the builder repo README for full instructions) nix build .#builder # Tell `enclavia reproduce` where to find it BUILDER_PATH=$(realpath result/bin/builder) enclavia reproduce 2f7e1a3c ``` The CLI prefers `$BUILDER_PATH`, then falls back to `builder` on `$PATH`. ## No provenance recorded Enclaves built by an older backend (or by a deployment without `FLAKE_LOCK_PATH` configured) have no recorded revs. The command still runs — your local PCRs are compared against the ones the backend recorded — but the rev-pinning hint is suppressed: ``` Recorded revs: none (built by an older backend; can't pin local rebuild to source) ``` If reproduction fails for such an enclave there's no canonical source-pin to fall back to; you'll have to figure out which revs the deploying backend was on at the time by other means (git log, deployment history, etc.).