How agentctl authenticates every caller, isolates tenants, hardens workloads, and manages its certificate fabric — for reviewers and operators hardening a production install. For the component overview see architecture.md; for day-2 procedures (enabling gates, rotating tokens, retrieving certs) see operations.md.
agentctl treats identity as cryptographic, and splits its surfaces into two classes with different roots of trust:
- Control / management path — strongly authenticated. Callers that drive an agent (management verbs, A2A ingress that reaches the pod) present a cert-manager-issued mTLS client certificate that identifies them as the single Management origin. Management access through the aggregated API is additionally gated by Kubernetes RBAC (SubjectAccessReview) per verb.
- Data-plane utility path — attested and network-isolated. Agents dial the gateways (intelligence, tools, coordination) keyless; the gateway derives the caller's tenant from its attested source IP (the pod IP, resolved to a pod through the Kubernetes API), and NetworkPolicies confine which pods can reach which surface at all.
Two properties hold across both classes:
- Agents are secret-free. An agent pod never holds a provider API key or an MCP tool credential; the gateways inject those off-pod. The only key material on the pod is its own rotatable serving key plus the public CA bundle.
- Agent pods are confined. Nonroot, no privilege escalation, all Linux
capabilities dropped, read-only root filesystem, no auto-mounted ServiceAccount
token,
RuntimeDefaultseccomp — satisfying therestrictedPod Security Standard.
┌──────────────────────────────────────────┐
kubectl (RBAC user) │ control-plane namespace │
│ │ │
▼ SAR per verb │ apiserver ─┐ │
aggregated apiserver ─┼────────────┤ mTLS client cert │
│ A2A gateway ┘ (= Management origin) │
│ │ │
└────────┼───────────────────────────────────┘
│ dial pod directly, mTLS :8443
▼
┌──────────────────────┐
agent ── keyless ──▶│ tenant agent pod │◀── ingress: control plane only
dial (source-IP │ (secret-free, hard- │
attested) :443/:8080 │ ened, netpol'd) │──▶ egress: DNS + gateways only
└──────────┬───────────┘
│ keyless, source-IP attested
┌─────────────────┼──────────────────┐
▼ ▼ ▼
modelgateway mcpgateway coordination
(injects provider (injects tool- (work.* claim
credential) server credential) leasing)
Every rendered agent pod serves its control surface (self-MCP + A2A) over mTLS HTTPS on port 8443. The operator renders the agent with these serve arguments:
--serve-mcp https://0.0.0.0:8443
--serve-cert /etc/agentctl/tls/tls.crt
--serve-key /etc/agentctl/tls/tls.key
--serve-client-ca /etc/agentctl/ca/ca.crt
--tls-ca /etc/agentctl/ca/ca.crt
The agent verifies the client certificate of any caller against the pinned
cluster CA (--serve-client-ca). A caller that presents a CA-signed client cert
authenticates as the Management origin — the only origin permitted to drive
management and A2A methods on the agent. A caller without a valid Management
client certificate is rejected.
Exactly two control-plane components present this client certificate:
| Component | Reaches the agent for |
|---|---|
| apiserver | management verbs (drain, lame-duck, cancel, pause, resume) |
| gateway (A2A) | inbound A2A message/send / message/stream |
Both dial the target pod directly over mTLS using the shared client cert
agentctl-client-tls (common name agentctl-control-plane). Because the identity
is a certificate, there is no bearer token on the agent pod to steal, and no
network position confers Management authority — only the private key does.
An agent dials the gateways keyless: it holds no credential and asserts its own identity. In the hardened default the gateways do not trust that assertion. Instead they read the kernel-set source IP of the TCP connection — the pod's own IP, which the pod cannot forge — and resolve it to the calling pod through the Kubernetes API. The pod's namespace is the authoritative tenant used to select the ModelPool, meter tokens, scope tool access, and enforce budgets.
This attestation is enabled by default (modelgateway.attestIdentity: true; the
MCPGateway is always attested). It is robust precisely because agent pods are
confined: with capabilities.drop: ["ALL"] a tenant pod has no CAP_NET_RAW and
cannot craft raw packets to spoof a source IP, so the kernel-attributed source IP
is a trustworthy tenant identity. A tenant therefore cannot bill or borrow another
namespace's model pool or tool set.
If a request also carries an advisory X-Agent-Namespace header that disagrees
with the attested namespace, the attested namespace always wins and the
mismatch is recorded as a spoof attempt.
The source-IP → pod resolution is a small, cache-backed lookup:
- Read the connection's source IP (the pod IP).
- List/get pods and match the IP against
status.podIPand thestatus.podIPslist (dual-stack); the field selector is treated as advisory and the match is re-verified locally. - Derive the identity from the matched pod: the namespace is the tenant; the
agent name is the operator-set
agentctl.dev/agentlabel, falling back to the pod name. - Cache the
IP → identitymapping with a short TTL (10s) so a burst of requests from one pod does not hammer the API server, while a deleted-and-recycled IP is re-attested quickly.
Because a freshly scheduled pod may issue its first dial before its status.podIP
has propagated into the gateway's watch cache, the resolution retries briefly
before failing closed — a startup-timing accommodation, not a relaxation of trust.
An IP that resolves to no pod is rejected; the gateways never fall back to the
spoofable header.
The same source-IP attestation optionally guards work-claim ownership on the
coordination server (coordination.attestIdentity: true), binding each claim's
lifecycle to the attested caller so one tenant cannot ack or release another
tenant's claim.
No agent pod holds a provider or tool credential. Credentials live in Kubernetes Secrets that only the brokering gateway can read, and are injected on the wire, off the pod:
| Plane | Broker | Where the credential lives | How it is injected |
|---|---|---|---|
| Intelligence | modelgateway | ModelPool.spec.credentialSecretRef (Secret + key) |
Attest caller → select the pool → attach the provider key on the upstream request → meter tokens → enforce the budget |
| Tools | mcpgateway | MCPServerSet server auth.tokenSecretRef (staticToken mode) |
Attest caller → scope to the agent's bound MCPServerSet → attach the server token (Authorization: Bearer …, or a custom header) on the upstream MCP call |
The gateways' Secret access is RBAC-scoped. In production, set
modelgateway.secretsNamespaces and mcpgateway.secretsNamespaces to the
namespaces that actually hold the credential Secrets; the chart then drops the
cluster-wide secrets get/list grant and renders a namespaced Role + RoleBinding
in each listed namespace instead. Left empty, the gateways get a broad
cluster-wide secrets get/list (a dev default with a large blast radius).
A ModelPool and its credential Secret:
apiVersion: v1
kind: Secret
metadata:
name: provider-credentials
namespace: default
type: Opaque
stringData:
api-key: sk-... # only the ModelGateway ever reads this
---
apiVersion: agentctl.dev/v1alpha1
kind: ModelPool
metadata:
name: mockpool
namespace: default
spec:
provider: mock
endpoint: http://mock-provider.default:8080
credentialSecretRef:
name: provider-credentials
key: api-key
models: ["mock-model-v1"]
defaultModel: mock-model-v1
budget:
maxTokens: 150 # pool-wide token cap (see Token budgets)The operator renders every tenant agent pod (and every control-plane pod) with a confined security context. For a tenant agent the exact rendered fields are:
# container securityContext
securityContext:
runAsNonRoot: true # runAsUser is NOT pinned — the image's
allowPrivilegeEscalation: false # native USER (e.g. 65532) runs unchanged
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
# pod securityContext
securityContext:
seccompProfile:
type: RuntimeDefault
# pod spec
automountServiceAccountToken: false # no ambient ServiceAccount token
shareProcessNamespace: true # agent is not PID 1 (correct orphan check)Supporting facts:
- Writable paths are explicit. With
readOnlyRootFilesystem: true, the only writable location is anemptyDirmounted at/tmp. The serving cert (/etc/agentctl/tls) and CA bundle (/etc/agentctl/ca) are read-only mounts. - No borrowed and no ambient credentials.
automountServiceAccountToken: falsekeeps the namespace default ServiceAccount token off the pod; combined with the secret-free model, the pod carries only its own serving key and the public CA. - No privileged component. Nothing in the control plane requires
hostPath,hostPID, or privileged mode. The control-plane namespace and tenant namespaces run at thebaselinePod Security level (namespace.podSecurity: baseline), while every workload self-confines torestricted. - Ports. An agent exposes
mcpon 8443 (mTLS) andmetricson 9090; its readiness probe is/readyzon the metrics port, which drain/lame-duck flip so that "ready" means "accepting work".
Tenant isolation is enforced by four NetworkPolicies applied in every agent
namespace. The first three select agent pods by the label
app.kubernetes.io/name: agent; the fourth selects only
identity-provisioned (AAuth) agent pods by agentctl.dev/aauth: "true" and is
inert in a namespace with none.
| Policy | Type | Effect |
|---|---|---|
agent-default-deny |
Ingress + Egress | Deny all traffic in and out by default (no rules). |
agent-allow-controlplane-and-dns |
Egress | Re-open egress only to DNS (UDP/TCP 53, any namespace) and to the control-plane gateway pods — ModelGateway, MCPGateway, A2A gateway, coordination — on TCP 443 and TCP 8080. |
agent-ingress-controlplane-only |
Ingress | Accept ingress only from the control-plane namespace (the apiserver + A2A gateway reaching the agent's mTLS 8443). No cross-tenant pod-to-pod traffic. |
agent-aauth-internet-egress |
Egress | For AAuth identity-provisioned agents only (RFC 0024): HTTPS (TCP 443) to public address space — 0.0.0.0/0 / ::/0 minus private, link-local, and CGNAT ranges — so direct signed dials to remote AAuth resources (and a public Agent Provider) work while lateral movement into cluster/private space stays default-denied. Vanilla NetworkPolicy cannot express per-FQDN egress; this is the honest coarse tier — a DNS-aware CNI (Cilium/Calico) can tighten it to the declared endpoints. |
Design points worth noting for review:
- Egress is pod-scoped, not namespace-scoped. The egress allow uses a
namespaceSelector(the control-plane namespace) and apodSelectormatching only the four gateway app names. A bare namespace selector would also expose the admission webhook and the aggregated apiserver to a tenant agent; the pod selector forbids that. The apiserver and admission app names are explicitly not in the allow set. - No internet, no other tenants — unless identity-provisioned. For a plain
agent the only permitted egress is DNS and those gateway pods; there is no
allow for the public internet or for peer tenant namespaces. An AAuth
agent (labeled
agentctl.dev/aauth: "true"by the operator) additionally gets public-HTTPS egress viaagent-aauth-internet-egress— the declared, admission-gated (capabilities.egress) exception for direct signed dials, still excluding every private range.
The chart renders these four policies for the namespaces statically listed in
networkPolicies.agentNamespaces. That does not cover a tenant namespace created
after install, so on every Agent/AgentFleet reconcile the operator
also ensures the same four policies in the workload's own namespace. The two
sources use byte-identical names and bodies and are applied server-side, so they
co-own each object rather than conflict. The operator-reconciled policies are
namespace singletons carrying no owner reference, so deleting one Agent never
tears down the namespace's isolation.
Both the chart and the operator gate on the same flag
(networkPolicies.enabled, default off) and require the control-plane namespace
to be known (the operator's POD_NAMESPACE); absent it, the operator skips the
ensure path rather than render an over-broad policy.
Requires a policy-enforcing CNI. NetworkPolicies are enforced only by a CNI that implements them (Calico, Cilium). kind's default
kindnetignores them — the objects render correctly but are inert. Verify your CNI before relying on tenant isolation.
A validating and a mutating webhook (served over HTTPS; the kube-apiserver is the
only client) apply the policy checks that CRD-level validation cannot express.
Both webhooks evaluate Agent at spec.* and AgentFleet at spec.template.*
— and additionally an AgentFleet's spec.coordinator.template.* — so a fleet's
worker or coordinator template is held to exactly the same bar as a standalone
agent.
- Image-registry allow-list. If configured,
spec.imagemust be prefixed by an allowed registry, else the request is denied. The default is a non-empty list —admission.allowedRegistriesships asagentd:,mock-agent,agentctl/,gcr.io/,registry.k8s.io/,ghcr.io/— so the allow-list is on by default. Set it to your own registries in production; an empty value disables the check (allow any registry). - Lethal-trifecta opt-in gate. An agent that declares all three of
capabilities.exec: true,capabilities.egress: true, and a non-emptycapabilities.secretslist requests the "lethal trifecta" and is denied unless it carries the annotationagentctl.dev/allow-trifecta: "true"(the value must be literally"true"). Any two of the three legs is permitted without the annotation. - ModelPool existence. If
spec.model.poolnames a pool, it must exist in the same namespace. (A transient API-server error during the lookup fails open — the cross-object check is skipped rather than blocking an otherwise-valid admission.) - OIDC policy well-formedness. If
spec.access.oidcis present,issuermust be a non-emptyhttps://URL,audiencesmust list at least one non-empty value, and anyjwksUrioverride must behttps://. This rejects a gate-widening typo (for example an emptyaudiences, which would otherwise accept anyaud) at admission rather than failing opaquely at request time.
The mutating webhook returns a JSON Patch of secure defaults, each conditional on the field being absent so it never clobbers an author's explicit value:
- standard
app.kubernetes.io/*labels (managed-by,part-of,name); mode⇒once(the conservative run-once shape);surfaces⇒ all-false(management,metrics,a2a) — minimal exposure; the network-exposeda2asurface never defaults on.
Token consumption is metered and capped at the ModelGateway, which is the only component that sees inference traffic:
- Pool-wide budget —
ModelPool.spec.budget.maxTokenscaps total consumption across every agent that uses the pool. - Per-fleet budget — an
AgentFleet.spec.budgetcaps the fleet's own token consumption, enforced alongside the pool budget so a single fleet cannot exhaust a shared pool.
The gateway attests the caller (source-IP identity above), meters tokens against the selected pool and, for a fleet worker, against the fleet, and enforces the cap with an atomic reserve-then-reconcile so the limit holds under concurrent requests. A request that would exceed a budget is rejected.
cert-manager is a required prerequisite — it issues all control-plane TLS. The chart bootstraps a self-signed issuer, mints a cluster CA, and exposes that CA as an issuer for every leaf:
agentctl-selfsigned (ClusterIssuer, self-signed)
│
▼
agentctl-ca (Certificate, isCA) ──► agentctl-ca (ClusterIssuer)
│
├─ agentctl-apiserver-tls aggregated apiserver serving
├─ agentctl-admission-tls admission webhook serving
├─ agentctl-modelgateway-tls ModelGateway serving
├─ agentctl-mcpgateway-tls MCPGateway serving
├─ agentctl-client-tls mTLS CLIENT cert (CN agentctl-control-plane)
│ = the Management origin into agents
└─ <workload>-serving-tls PER-AGENT serving cert (operator-issued)
If you already run a cluster CA, set certManager.caIssuerRef and the chart skips
the self-signed bootstrap and issues every leaf (including the per-workload agent
serving certs) from your issuer.
For each reconciled Agent/AgentFleet the operator ensures, in the workload's
namespace:
- a cert-manager
Certificateminting the workload's serving identity into the Secret the pod mounts (<workload>-serving-tls, keystls.crt/tls.key). The SANs cover the (headless) Service name (<workload>.<ns>.svcand its.cluster.localform) and the per-pod DNS form*.<ns>.pod.cluster.localso the control plane can address and verify a single replica. Keys are ECDSA P-256; the cert has a 90-day lifetime (duration: 2160h) and renews 30 days early (renewBefore: 720h), reloaded in place. The Certificate is owner-ref'd to the CR so garbage collection reclaims it. - the
agentctl-caConfigMap (keyca.crt) carrying the cluster CA public certificate. The pod mounts it as its client-CA (to authenticate Management callers) and as its outbound trust anchor (to verify the gateways). It is namespace-shared and deliberately un-owned, so deleting one agent never removes the namespace's trust anchor.
All leaves are pure-Rust rustls/ring (no OpenSSL/aws-lc). Public OIDC issuers,
by contrast, are reached over the internet using the bundled Mozilla trust anchors
— never the internal control-plane CA.
Management verbs (drain, lame-duck, cancel, pause, resume, and status) are served
by the aggregated apiserver under management.agentctl.dev. Every request is
doubly authorized:
- Kubernetes RBAC admits the caller to the aggregated APIService (the
kube-apiserver front-proxy authenticates the aggregator via the
requestheader-CA and asserts the user via
X-Remote-User/-Group). - A SubjectAccessReview per verb — the apiserver asks the Kubernetes
authorizer whether this subject may perform this management verb on this
agent, and denies (403) on a negative. A caller with no RBAC binding (for
example
kubectl ... --as=nobody) is refused.
Only after both pass does the apiserver dial the target pod(s) under the Management client certificate. Fleet verbs fan out to all replicas.
For A2A ingress the gateway supports three inbound-auth mechanisms, evaluated by
the A2A RPC handler in a fixed precedence. All are off by default (the in-cluster
default is an unauthenticated A2A surface; front it with an Ingress/LoadBalancer
for transport TLS). Probes, /metrics, and the public JWKS
(/.well-known/jwks.json, the Agent Card verification key) are never gated.
Precedence for a call to POST /agents/{ns}/{name}:
- Trusted front-proxy identity (verified mTLS listener) — highest precedence;
- else per-agent OIDC when the target's
spec.access.oidcis set; - else the coarse bearer token (
AGENTCTL_API_TOKEN).
Reading the access policy fails closed: a hard error fetching the CR returns 502 rather than admitting the call.
An optional shared in-cluster gate (apiToken.enabled, default off). When set,
the coordination server, ModelGateway, A2A gateway, and scaler require
Authorization: Bearer <token>; the compare is constant-time. The chart mints a
lookup-stable Secret (agentctl-api-token) kept across upgrades, wires it into
those services, and the operator injects the same secretKeyRef into agent pods
in the control-plane namespace. This is a coarse gate — one token, no per-caller
identity — not a substitute for the attested identity above. (A secretKeyRef
cannot cross namespaces, so agents outside the control-plane namespace need the
Secret replicated into their namespace; see operations.md.)
An Agent (or AgentFleet template) can declare a per-agent OIDC policy in
spec.access.oidc. The A2A gateway then verifies the caller's
Authorization: Bearer <JWT> for that specific agent:
spec:
surfaces:
a2a: true
access:
oidc:
issuer: https://login.acme.example # required, https://
audiences: [support-bot] # required, non-empty
jwksUri: https://login.acme.example/keys # optional; else OIDC discovery
requiredClaims: # AND across entries…
- claim: groups
anyOf: [support] # …OR within one entry
forwardIdentity: true # forward identity to the agentEnforcement:
- JWKS discovery + caching. The key set is discovered from
jwksUri, else via the issuer's…/.well-known/openid-configuration, and cached per issuer with a 300s TTL; akidmiss inside a fresh cache forces one refresh (key rotation). - Verification. The signature is checked against the matching JWK; the
algorithm is pinned to the key's family (not the token header) to block
alg-confusion attacks.issmust equalissuer,audmust intersectaudiences, andexp/nbfare validated with a 60s leeway. - Authorization. Every
requiredClaimsentry must be satisfied (logical AND); within an entry the caller's claim matches by array-contains or scalar-equals againstanyOf. An emptyanyOffails closed. - Result. Authentication failures return 401, authorization failures
403; the response body never leaks token detail (the reason is logged
server-side). On success, with
forwardIdentity: true, the verified identity is forwarded to the agent asX-Auth-Subject/X-Auth-Email/X-Auth-Groups(client-suppliedX-Auth-*headers are not propagated, so the agent can trust these to be gateway-verified). The agent never sees or verifies the raw token.
This is native to the CR — no service mesh, sidecar, or external gateway policy is required to gate traffic.
When edge auth is terminated at a fronting API gateway (APISIX, Kong, Envoy) that
asserts the authenticated identity as headers, agentctl must trust the channel,
not headers from anyone — otherwise any in-cluster pod could forge an identity
header. Trusted-proxy mode (trustedProxy.enabled, default off) mirrors the
Kubernetes aggregated-apiserver front-proxy pattern:
- Authenticate the proxy over mTLS. The gateway opens a second listener on
:8443that requires a client certificate chained to the trusted-proxy CA (trustedProxyCA). After the chain verifies, the peer cert's CN/SAN must be in thetrustedProxy.allowedNamesallow-list, else 403. An empty allow-list fails closed. - Trust the asserted identity headers only over that channel. On the verified
mTLS listener the gateway reads the proxy-asserted identity from
<prefix>-subject/<prefix>-email/<prefix>-groups(trustedProxy.headerPrefix, defaultx-agentctl; individual names overridable viatrustedProxy.identityHeaders). - Authorize, then forward. The gateway runs the target agent's
spec.access.oidc.requiredClaimsagainst the asserted identity, denies on mismatch, and forwards the identity to the agent (as the sameX-Auth-*headers as the OIDC path).
Anti-spoof is symmetric. On the plaintext :8080 listener — and on any
non-trusted path — the gateway strips the identity headers (the configured
<prefix>-* names plus the legacy X-Forwarded-User/-Email/-Groups) before
handling, so a caller that did not arrive over the authenticated mTLS channel can
never assert an identity.
The trusted-proxy and native-OIDC paths are two front-ends to the same
requiredClaims authorization and identity-forwarding core — choose where the JWT
is verified: at the gateway (native OIDC) or at the edge proxy (trusted-proxy).
The chart issues the proxy's client cert (agentctl-trusted-proxy-client-tls,
default CN apisix) off the agentctl CA; its CN/SAN must be on allowedNames.
- cert-manager installed and healthy (required — all control-plane TLS).
- Scope gateway Secret access: set
modelgateway.secretsNamespacesandmcpgateway.secretsNamespacesto the credential namespaces to drop the cluster-widesecretsgrant. - Keep source-IP attestation on (
modelgateway.attestIdentity: true,coordination.attestIdentity: true); disable only for a trusted single-tenant install. - Enable NetworkPolicies (
networkPolicies.enabled: true) and confirm a policy-enforcing CNI (Calico/Cilium); list tenant namespaces innetworkPolicies.agentNamespaces(dynamic namespaces are also covered by the operator). - Tighten the registry allow-list (
admission.allowedRegistries) to your own registries. - Authenticate A2A ingress for internet exposure: per-agent OIDC
(
spec.access.oidc) or trusted-proxy mode, fronted by an Ingress/LB for TLS. - Consider the coarse
apiToken.enabledgate for in-cluster data-plane paths, and the opt-incoordination.mtls/ PostgresverifyFullhardening — see operations.md.