Agent identity
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
- Zero standing agent secrets. No long-lived credential is ever placed in an agent’s environment, filesystem, or memory. Identity is ambient.
- 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.
- 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.
- 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.
- 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.
- 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 assurancenoneunder 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 carriesclassbeside the attestedtier, and the echoverify/llmproxy surfaces report both. On the dev cluster a kata-class agent therefore showsclass=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:
- A per-agent gateway CA is trusted by the agent’s TLS stack (see “CA injection” below).
- Egress is transparently redirected to the network gateway (belt: Cilium
default-deny; suspenders: redirect, not reliance on
HTTPS_PROXY, which arbitrary code can unset). - 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
auditorrole, 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:
- Sever external reach. The controlplane flips
agent.containedin 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-allegressDenyCiliumNetworkPolicy into the agent’s namespace: defence in depth below the redirect. - 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. - 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.