Daevix Docs

Agent identity

View as Markdown Edit on Tangled
This page is the engineering document, published as-is from the repository. It is reconciled with the code on every change, so it is denser than a written guide and its section references point at the source rather than at this site. docs/agent-identity.md · 47465946

How an agent running in Daevix gets an identity, and how that identity is authenticated. This is the design that the enclave data plane (broker, network gateway, LLM gateway, IMDS) is built to enforce.

Read architecture.md first for the controlplane/enclave split and the sovereignty invariants this builds on.

Threat model

An agent is arbitrary, untrusted code. We schedule a customer’s workload and must assume it is hostile: it can read its own filesystem and environment, inspect its own memory, make syscalls, and try to reach the network. The platform exists to observe and govern that workload, so the workload has a direct incentive to escape observation.

The single consequence that shapes everything below:

An agent must never hold a credential, because a credential an agent holds is a credential an attacker who owns the agent can exfiltrate and replay.

The core inversion: attribute, don’t authenticate

The obvious approach is to authenticate the agent: hand it a single-use bootstrap token (delivered in an env var), exchange that for a rotating refresh token plus short-lived JWT/mTLS credentials, and make the root of trust something the agent presents. That is exactly the thing that does not survive the threat model — the bootstrap token, the refresh token on disk, and the JWT are all extractable by the code they are meant to authenticate, and a compromised agent simply is that identity until the TTL lapses.

Daevix does not authenticate the agent at all. It attributes every action to an agent using properties of where the agent runs, enforced by a layer below the agent that its code cannot reach — the kernel, the CNI datapath, the hypervisor/TEE. The agent presents nothing.

This is the AWS instance-metadata analogy: a VM does not authenticate to 169.254.169.254; the hypervisor knows which VM is calling because of the path the request took, not because the VM presented a secret. The network fabric attributes a VM’s traffic to it because the fabric stamps and validates the source beneath the guest. We reproduce that with three enforcement layers.

Invariants

  1. Zero standing agent secrets. No long-lived credential is ever placed in an agent’s environment, filesystem, or memory. Identity is ambient.
  2. Attribution below the agent. Every “who is this” decision is made from a signal the agent’s code cannot forge: the socket it is mounted, the Cilium identity on its packets, or the TEE measurement of its guest.
  3. Total mediation — no uninspected egress. Every outbound connection an agent makes is terminated, inspected, and policy-applied at an enclave gateway, or it is denied. This is protocol-complete: a protocol the gateway cannot inspect is refused, never forwarded opaquely. TLS-over-TCP is MITM-terminated (leaf forged from the injected CA); UDP/QUIC (HTTP-3) is likewise only permitted by terminating it — a QUIC-terminating MITM using the same forged-leaf trust — and anything we cannot terminate (arbitrary UDP, other L4) is default-denied and audited. Direct or uninspected egress is not expressible from inside the namespace; it is dropped by the datapath, not by agent cooperation. This “inspect everything or deny it” rule is the platform’s core containment guarantee.
  4. Bring your own image. The agent’s application image is arbitrary and never modified. All trust machinery is supplied by the Daevix-controlled runtime and substrate, never by requiring a specific base image or an embedded SDK.
  5. Validity equals liveness. An agent’s identity exists exactly as long as its pod is provisioned. There is no secret to rotate and none to revoke; tearing down the pod removes the identity in the same operation.
  6. Mediation runs outside the agent’s VM. Every identity and mediation function (IMDS, gateways, broker) is a trusted Daevix instance in the enclave’s trusted plane, never a sidecar inside the agent’s Kata VM - the boundary it exists to enforce.

The three enforcement planes

                          enclave (data plane)
   ┌───────────────────────────────────────────────────────────────┐
   │  broker ── provisions ──▶ per-agent namespace (Kata pod)        │
   │    │                        ┌───────────────────────────────┐   │
   │    │  Plane 3: TEE          │  arbitrary agent image (BYO)  │   │
   │    │  attest guest at       │                               │   │
   │    │  provisioning          │   ┌── mounts ──┐               │   │
   │    ▼                        │   ▼            │ egress only   │   │
   │  socket→agent map           │  IMDS unix   Cilium default-  │   │
   │  Cilium id→agent map        │  socket      deny → gateways   │   │
   │                             └───┼────────────────┼──────────┘   │
   │              Plane 1: attribute │   Plane 2: attribute          │
   │              by which socket    │   by Cilium identity          │
   │              ▼                  │   ▼                           │
   │           IMDS service        network gateway / LLM gateway     │
   │           (metadata,          (TLS-terminate, inspect, govern,  │
   │            per-req tokens)     inject egress credentials)       │
   └───────────────────────────────────────────────────────────────┘

Plane 1 — Local metadata: attribution by the mount

The broker creates one IMDS unix socket per agent on the host (/run/dvx/agents/<agent-uid>/imds.sock) and bind-mounts only that one into that one pod at the well-known path (/run/dvx/imds.sock). Identity is which listener the connection arrived on: the broker owns the socket→agent mapping, and the agent cannot see or reach any other agent’s socket (pod/VM isolation, plus only its own socket is mounted). The agent asks “who am I?” and the IMDS answers from the mapping — the agent presented nothing.

SO_PEERCRED is not the primary signal. Across the Kata VM boundary (virtio-fs / vsock) host-side peer pids and uids are virtualized and largely meaningless. Peercred is kept only as an optional defense-in-depth sanity check; the socket identity is the mount.

The IMDS unix socket is in-namespace local IPC, not network egress, so the default-deny egress policy (Plane 2) does not apply to it. Local metadata and network traffic are deliberately governed by different mechanisms.

Plane 2 — Network: attribution by Cilium identity

Every agent runs in its own namespace with Cilium default-deny egress. The only permitted route out is to the enclave network gateway (and the LLM gateway for model traffic). The agent’s arbitrary code cannot route a packet anywhere else — the eBPF datapath drops it. There is no “forgot to proxy” failure mode because direct egress is not expressible from inside the namespace.

The same eBPF datapath that forces the path also carries the pod’s Cilium security identity, derived from its k8s identity and validated in-kernel, unforgeable by the workload. So the gateway attributes every connection to an agent ambiently — no token, no per-request auth from the agent. The layer that forces the path is the layer that names the agent: mediation and identity are one mechanism, not two.

How attribution is wired (implemented). East-west traffic is not masqueraded, so the gateway sees each connection’s real pod IP as the TCP source. internal/enclave/attribution lists CiliumEndpoint objects (stdlib REST over the in-cluster ServiceAccount - no client-go) and maintains pod-IP -> {agent labels, numeric security identity}. The gateway joins the source IP to that index and audits actor="agent:<id>" with the org and the Cilium security_id. Today one node-local gateway serves many agents, so this join is on the path; per-agent gateways would make it implicit.

The enforcement seam (bd_2_0-336, implemented). Every terminated flow - TCP and QUIC alike - is evaluated by a single PolicyEngine.Evaluate(EgressRequest) -> Decision seam (internal/enclave/gateway/policyengine.go) using the attributed identity, not just the source IP, and the verdict is audited as a structured decision (policy_scope, policy_reason code, matched policy_rule). Because mediation and identity are one mechanism, the default engine fails closed on identity: when attribution is in force, an egress that resolves to no agent is denied with reason no_identity (not merely audited by IP) - we will not govern a flow we cannot name. The default staticEngine is the degenerate case (the DVX_ALLOW_DESTS default-deny hostname allowlist); the layered governance engine (below) plugs into the same seam without touching the request path.

Layered governance policy (bd_2_0-apa, implemented). Policy is authored at four scopes, broad -> specific: Platform -> Org -> Group -> Agent. Platform is the operator running the controlplane - in a multi-tenant SaaS, a different principal from any tenant - so platform policy is the safety authority a tenant cannot escape. Group is a subdivision within a tenant (a team, environment, or project) that the old design lacked. A more-specific scope OVERRIDES a broader one unless the broader one LOCKS the setting; agents can never lock (a new dvx.io/group-id pod label carries the group into AgentIdentity). Two composition laws (internal/policy, a pure resolver shared by both planes): scalar settings resolve by lock-aware most-specific-wins (broadest lock pins); destination policy unions DENY across every scope always (a hard floor no lower scope can remove - a platform deny binds with no lock needed) while ALLOW follows most-specific-wins/lock, and the decision is deny-trumps-allow then default-deny. The controlplane stores this policy, builds a policy.Snapshot, and publishes it out-of-band (internal/controlplane/govpolicy, mirroring attestation reference-values): only policy metadata crosses, in the CP→enclave direction, never sovereign data (invariant 2). The gateway’s layeredEngine runs the same resolver over that snapshot locally (hot-reloaded), so a QUIC decision and a TCP decision are governed identically. Authoring is dvx policy destination --org/--group/ --agent [--lock], which refuses an agent lock and refuses to author a key a higher scope has locked.

One controlplane, many enclaves (bd_2_0-apa.9). A single controlplane serves many enclaves, so the snapshot is scoped per enclave: each enclave gets the platform floor plus only the org/group/agent policy for the single org it serves, so no org’s rules ever land in another org’s sovereign enclave. An enclave serves exactly one org (bd_2_0-j9r): enclave.owning_org_id is required and authored at registration (dvx internal register --owning-org, or dvx enclaves register-broker --owning-org); an org may own many enclaves. It is deliberately not derived from agent placement: the controlplane stays agent-agnostic (enforcement and provisioning are enclave-local), which avoids pulling agent inventory up into the management plane. dvx serve controlplane --policy-publish-dir fans a full re-publish out to <dir>/<enclave_id>.json, one scoped file per enclave; because every file carries the platform floor and an org’s rows land only in the files of enclaves owned by that org, a platform change reaches every enclave and an org change reaches only that org’s — no incremental event routing. Those per-enclave files back the controlplane’s authenticated pull endpoint: each enclave’s gateway runs a policy-pull sidecar that holds an SSE stream for a refresh nudge and pulls its own scoped file (bd_2_0-apa.10) — the sole delivery path, since enclaves run in the customer’s cluster where a ConfigMap could never reach them (bd_2_0-apa.15). This is permitted because a snapshot is platform-owned metadata (not sovereign data): invariant 2 forbids sovereign data leaving the enclave, not the enclave fetching platform metadata. For air-gapped enclaves the same file may be delivered at rest by any out-of-band channel. Either way the controlplane never dials the enclave and never sees sovereign data. There is no request-time served-org gate (the bd_2_0-apa.12 guard was retired with shared enclaves): a foreign-org agent simply finds no allow rows and hits default-deny — no cross-org reach, enforced by the same resolver.

Plane 3 — Provisioning root: the Kata TEE measurement

Planes 1 and 2 both rest on a mapping — this socket / this Cilium identity == agent N of org M — established at the one moment the workload is trustworthy: when the enclave provisions the pod. The agent never participates in its own enrollment, so there is no bootstrap secret to steal.

That mapping is anchored in hardware. Before the broker mints the per-agent socket or installs the Cilium-identity→agent mapping, it requires a Kata TEE remote-attestation report (SEV-SNP / TDX / vTPM) and verifies the guest’s measurement against expected reference values. Only a pod whose measured guest matches gets a socket and a mapping. The ambient “who” is rooted in a hardware signature over the actual running guest, not merely in “the broker asserts it.”

What is measured (and why BYO image still works). The reference values cover the Daevix-controlled Kata guest and injected runtime — the guest kernel, the guest agent, and the Daevix runtime layer that installs the CA, mounts the socket, and wires egress. They do not cover the customer’s application image, which is arbitrary and unmeasured by design. We do not attest the payload; we attest the envelope the payload runs inside. That envelope is fixed and Daevix-built, so stable reference values exist even though the agent image is unknown.

Boundary note. Attestation policy (acceptable reference values / allowed guest measurements) is an identity concern the controlplane owns, but sovereign data never leaves the enclave and the controlplane never sees it. Attestation evidence is a measurement, not sovereign data. So: the controlplane publishes reference values and the enclave verifies the report locally at provisioning time. Reference values reach the enclave over the same CP→enclave metadata channel governance policy uses — a refvalues-pull sidecar next to serve imds holds an SSE stream and pulls this enclave’s reference-values policy over the authenticated endpoint (internal/enclave/cpsync, bd_2_0-4ef), writing the file the attestation gate re-reads per provision. Verification stays enclave-local; only policy crosses, and it crosses the allowed direction (per architecture invariant 2).

Requested isolation class vs discovered assurance tier (bd_2_0-cwnz.6). Plane 3’s assurance tier is discovered: what the guest actually PROVED at attestation, minted into the credential and gateable by floors. It is distinct from the enclave’s requested isolation class (internal/isoclass): the operator-authored, provision-time, structural envelope for the whole enclave (plain < vm < node-cvm < per-agent-tee; class describes what boundary CONTAINS the agent, tier what the guest PROVED). The broker enforces the two honestly:

  • The broker-side IsolationGate is exact-class-or-refuse (fail-closed): a driver provisions only a class it can satisfy exactly — no silent downgrade and no silent upgrade; ordering is used only by assurance floors. A missing (never authored) or unknown class is refused, never guessed to a weaker default.
  • Because the class is per-enclave, an unsatisfiable class stalls ALL provisioning for the enclave — so the refusal is operator-queryable status on the broker’s operator API (GET /api/v1/status/isolation) naming the mismatch (requested=X, driver-capability=Y), never just a log line.
  • The invariant assurance ≤ class holds always: a guest cannot have proven a stronger boundary than the envelope it was provisioned into; a violation fails loudly (a bug, never absorbed). A class stronger than the attested tier is fine by design (a TEE present but unverified). Deriving assurance FROM the class record is forbidden — what was requested is never evidence of what was proved. A vm-class enclave (e.g. the local micro-VM driver) honestly carries assurance none under the SAMPLE attestation gate.
  • The class travels as its OWN claim (never an overwrite of the tier): the broker authors the enclave’s class onto each AgentMapping (spec.identity.isolationClass), and the node-local SVID mint carries it as a DISTINCT dvx-extension claim beside the attested tier (agentid.CredClaims.Class / agentcert.CredClaims.Class; the SVID verify paths on both planes require BOTH claims well-formed). The agent-operator JWT likewise carries class beside the attested tier, and the echoverify/llmproxy surfaces report both. On the dev cluster a kata-class agent therefore shows class=node-cvm, tier=none — what was asked, and what was proved, in two separate claims. Minting fails closed on a missing/unknown class and loudly on the assurance > class violation.

Enclave decomposition: the trusted plane

The enclave is not one process; it is a set of Daevix-owned components, and the line that organizes them is the agent’s Kata VM boundary:

  • Inside the agent’s Kata VM: only the untrusted, bring-your-own agent image plus a minimal, measured Daevix runtime shim (CA injection, egress redirect setup). Nothing here sees plaintext, holds a key, or makes a policy decision. This is the only code the TEE measurement (Plane 3) needs to cover.
  • Outside the VM, in the enclave’s trusted plane: IMDS serving, the network gateway, the LLM gateway, and the broker. These see plaintext, hold the gateway CA private key, mint tokens, and write the audit stream.

The realization that decides placement: the Kata VM exists to contain untrusted customer code; the gateways are trusted Daevix code. They need isolation from the agent, not from Daevix - so they do not run in a Kata VM and, crucially, never as a sidecar inside the agent’s pod. A mediation component inside the boundary it is meant to mediate is no boundary at all: a compromised agent sharing its VM could read the CA key, forge its own audit, or disable its own governance.

Per-agent, out-of-VM instances

Identity and mediation functions are provisioned per agent, as dedicated instances outside the agent’s VM (a separate trusted pod with its own network namespace, to which the agent’s egress is routed). This makes the whole platform one pattern:

Every identity and mediation function is a dedicated instance the enclave owns; which instance handled the request is the identity, and the agent presents nothing.

That is the same trick as the per-agent IMDS socket (Plane 1), now applied to the gateways: a gateway that serves exactly one agent needs no attribution lookup - identity is implicit in the pairing, so Planes 1 and 2 collapse into one idea. It also bounds blast radius: an instance only ever sees one agent’s traffic, so no shared component holds every agent’s plaintext.

The density fallback, if one instance-set per agent proves too costly, is a per-node daemon (one gateway DaemonSet per node, serving all agent pods on that node, still outside every agent’s VM). That trades implicit attribution for the Cilium-identity join (Plane 2) - which is why that join stays in the design even though the per-agent placement would not strictly need it.

Node-local data path vs. HA control

The components scale differently, so they deploy differently:

  • Node-local (DaemonSet): IMDS socket serving is node-local by physics - a hostPath unix socket can only be served by a process on the same node as the agent pod (exactly as a cloud IMDS is answered by the local host, never a central service). This tier is never centralized; it is present on every node.
  • HA control: the broker’s provisioning, attestation verification, and identity issuance are stateless-over-shared-state and run as multiple replicas (leader election, or optimistic writes keyed by agent UID).

For HA to be safe, the socket->identity registry must be durable and watched, not in-memory: the authoritative mapping lives in shared enclave state (the enclave database, or a Kubernetes CRD / pod annotation written at provision time), and each node’s IMDS daemon watches it to serve its local sockets. A broker replica failing then changes nothing on the data path. (The in-memory internal/enclave/registry is the node-local cache tier of this; its source of truth is external.)

Total mediation and TLS termination

Governance and observability require the gateway to see plaintext, so it TLS-terminates agent egress:

  1. A per-agent gateway CA is trusted by the agent’s TLS stack (see “CA injection” below).
  2. Egress is transparently redirected to the network gateway (belt: Cilium default-deny; suspenders: redirect, not reliance on HTTPS_PROXY, which arbitrary code can unset).
  3. The gateway forges a leaf certificate per destination, decrypts, inspects and applies governance policy (allow/deny/redact on destination and payload), then re-originates TLS upstream to the real destination.

The LLM gateway is the same pattern specialized for model APIs — it terminates and inspects prompts and completions for the same observability and governance reasons.

How the redirect is wired (implemented). Step 2 is not HTTPS_PROXY; it is an eBPF-datapath redirect the agent cannot observe or bypass. A CiliumNetworkPolicy on app=agent (deploy/policy/agent-egress-redirect.yaml) redirects all agent TCP (port "0") into Cilium’s embedded Envoy (CiliumClusterwideEnvoyConfig, deploy/enclave/gateway-envoy-config.yaml), which forwards each connection to the node-local dvx-gateway (internal/enclave/gateway, a DaemonSet outside the agent’s VM) over a Unix socket carrying a PROXY-protocol v2 header. The Unix socket is mandatory: Cilium’s Envoy spoofs the upstream source to the agent’s pod IP (IP_TRANSPARENT), which would misroute a TCP gateway’s return path. The PROXY header is how the agent’s original destination and its real pod IP (the unforgeable “who”, joined to the Cilium identity in the attribution provider) survive the redirect. Any egress rule flips the endpoint to Cilium default-deny, so DNS and the redirect are the only reachable paths — lateral and direct egress are dropped by elimination. Step 3 (TLS termination + destination policy) is implemented on top of this datapath: the gateway forges a leaf per SNI, governs by a default-deny hostname allowlist, and re-originates validated TLS upstream (see “CA injection” below). See deploy/kind/README.md for the live proof.

CA injection without owning the image

Daevix lets customers run any container image: the Daevix runtime injects the CA at runtime rather than requiring a Daevix base image (invariant 4).

The CA certificate is the one piece of trust machinery that must live inside the agent’s filesystem view (its TLS libraries have to trust the gateway leaf). It is placed there by the Daevix runtime layer, not by the customer:

  • The runtime overlays / injects the CA into the standard trust locations and points TLS stacks at it (e.g. a read-only mount plus the conventional environment such as SSL_CERT_FILE, REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS), from a source the agent cannot swap out.
  • Because the CA distribution rides the Daevix runtime, it is part of the measured envelope (Plane 3), which ties trust distribution back to the attestation anchor.
  • Only the CA certificate (public) is ever in the agent’s view; the CA private key never leaves the gateway.

An agent that removes or ignores the CA only breaks its own TLS — it still cannot reach anything directly, because egress is default-denied to everything but the gateway (invariant 3). The CA is what makes mediated egress usable, not what makes it enforced.

How injection is wired (implemented). An init container running the agent’s own image (so it copies that image’s own trust roots) merges the gateway CA into a trust bundle in a shared volume, which is mounted back over /etc/ssl/certs in the main container; the belt-and-suspenders env vars above point runtimes that read them at the same bundle. The agent image is unmodified. See deploy/agents/demo-agents.yaml and, on the gateway side, the leaf-forging internal/enclave/gateway (certstore.go forges a per-SNI leaf; mitm.go / gateway.go terminate agent TLS, apply the destination allowlist, and re-originate a validated TLS connection upstream). Proven live: an unmodified curl agent reaches an allowlisted host over a gateway-forged leaf (HTTP 200), while a non-allowlisted host is cut after the handshake — see deploy/kind/README.md.

Credentials for external systems

Some calls genuinely need a real bearer credential at the external boundary (you cannot ambient-auth Google). The agent still never holds one:

  • Preferred — inject at egress. Because all egress is already terminated at the gateway, the gateway attaches the short-lived, minimally-scoped cloud credential on the way out. The agent may never fetch or hold one.
  • Fallback — per-use via IMDS. For SDKs that must sign client-side, the agent fetches a fresh, minimally-scoped, seconds-to-minutes token from its IMDS socket (ambiently identified, per-use, no refresh loop). No bootstrap token, refresh family, or rotation loop is involved at all.
  • Implemented — the operator API (agent-as-operator, bd_2_0-bvw). Egress toward the controlplane’s operator API gets a 60s, dvx-signed agent-operator token injected the same way: the gateway relays the agent’s held SVID cert (never the key) through the broker to the CP mint endpoint, caches the token per-agent (≤60s, drop-on-failure), and presents the bearer itself on the re-originated hop. The agent’s own TLS to the CP host is terminated at the gateway exactly like any other egress (the per-agent egress-redirect carries an explicit east-west rule for the controlplane), so the token and the SVID key both stay in the trusted plane. Blast radius is the own-actor audit read (the auditor role, kind-gated on every other route); the outbound cloud federation of the same token class is the WIF proof (docs/wif.md). Live-verified on daevix-dev (bd_2_0-bvw.7).

Lifetime equals liveness

There is no secret to rotate, so there is no rotation loop and no refresh-token family to protect. The identity is the socket, the Cilium identity, and the measured guest. Tearing down the pod removes the socket, the mapping, and the Cilium identity in the same operation — a token-revocation kill-switch (revoke the refresh family + blacklist the JTI, eventually consistent) becomes “unmap and unmount,” immediate and local.

Containment is not teardown

Teardown (the Lifetime equals liveness lever) destroys the VM — and with it the evidence. The composed contain agent action (bd_2_0-0qp, POST /api/v1/orgs/{org}/agents/{agent}/contain, released by .../release) freezes a suspect agent IN PLACE instead, escalating without destroying:

  1. Sever external reach. The controlplane flips agent.contained in the governance snapshot; the enclave gateway’s layered engine resolves it FIRST and denies the agent’s every egress — ahead of allow rules and ahead of the assert-identity floor — which also kills LLM inference (classification only runs on destination-allowed flows). The broker additionally stamps a deny-all egressDeny CiliumNetworkPolicy into the agent’s namespace: defence in depth below the redirect.
  2. Revoke agent-operator authority. The CP flag feeds the existing gates: the mint endpoint refuses contained agents (bd_2_0-bvw.3) and the broker’s relay gate refuses them before the CP (bd_2_0-bvw.5), so worst-case post-containment CP access is the ≤60s tail of already-minted tokens.
  3. Preserve evidence. The Kata VM is NOT torn down: memory and disk keep running for inspection. Operator forensics (ssh/exec/tunnel into the VM) deliberately stay reachable — inspect, then release or delete.

The action is a single operator verb, reversible, audited at authority tier (agent.contain / agent.release), and touches only the target agent. It composes existing primitives (the policy snapshot + layered engine, the mint/relay gates, the broker reconciler) rather than adding a new mechanism.

Open questions

  • Attestation freshness. Is a one-time attestation at provisioning sufficient, or should the gateway/IMDS periodically re-challenge? For Kata VM-isolated pods the boot-time measurement is usually the meaningful one, but it is a real choice.
  • Attestation on nodes without a TEE. Define the fallback posture for clusters whose nodes cannot produce a hardware measurement (dev/test, or non-confidential hardware). Likely a clearly-labelled reduced-assurance mode where the anchor degrades to enclave-asserted, never silently.
  • Cilium identity granularity. Confirm one Cilium security identity per agent (not per workload class), since Plane 2 attribution depends on it being agent-unique.
  • CA injection across runtimes. Enumerate the TLS stacks we must cover (OpenSSL, Go, Node, Python, JVM) and how the runtime points each at the injected CA without touching the customer image.