Agent Identity
Identity patterns for AI agents in Kubernetes: a cryptographic workload identity unique to each agent class, a compound dual identity that ties the agent to the human who launched it, and identity for the raw HTTP(S) calls an agent process makes outside the MCP protocol.
Why identity matters
Without verified identity, every agent looks identical in the audit log and every policy applies to all agents equally. With identity, AIControls can:
- Attribute every tool call and LLM request to a specific agent class in the audit log
- Enforce per-agent policies: tool allowlists, budget limits, HITL approval rules
- Detect behavioral anomalies per workload: deny-rate spikes, tool-call frequency deviations
- Answer compliance questions like "Which agent made this production change and who authorized it?"
Scenario 1: Unique agent identity via ServiceAccount + OIDC
Each agent Deployment gets a dedicated Kubernetes ServiceAccount. AIControls validates the projected SA token against the cluster's OIDC issuer and resolves namespace/serviceaccount as the agent's identity in every audit event and CEL policy.
identity.validationMode: k8s-tokenreview — which validates tokens directly via the Kubernetes TokenReview API without OIDC configuration — is a planned feature (gap G3). Today, use oidc with clusterOIDCIssuer as shown below.
Step 1 — Create a dedicated ServiceAccount per agent class
# agents/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: agents
labels:
aicontrols.nirmata.io/govern-agents: "true"
---
# One ServiceAccount per logical agent type
apiVersion: v1
kind: ServiceAccount
metadata:
name: claude-code-agent
namespace: agents
labels:
aicontrols.nirmata.io/governed: "true"
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-agent
namespace: agents
labels:
aicontrols.nirmata.io/governed: "true"
Step 2 — Find your cluster's OIDC issuer
- Amazon EKS
- Google GKE
- Azure AKS
- On-prem / kind
aws eks describe-cluster \
--name YOUR_CLUSTER_NAME \
--query 'cluster.identity.oidc.issuer' \
--output text
# → https://oidc.eks.us-east-1.amazonaws.com/id/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
GKE OIDC issuer follows this format:
https://container.googleapis.com/v1/projects/PROJECT_ID/locations/REGION/clusters/CLUSTER_NAME
# Confirm with:
gcloud container clusters describe YOUR_CLUSTER_NAME \
--region YOUR_REGION \
--format='value(name)'
# Construct: https://container.googleapis.com/v1/projects/$(gcloud config get project)/locations/REGION/clusters/NAME
az aks show \
--name YOUR_CLUSTER_NAME \
--resource-group YOUR_RESOURCE_GROUP \
--query 'oidcIssuerProfile.issuerUrl' \
--output tsv
# → https://eastus.oic.prod-aks.azure.com/TENANT_ID/CLUSTER_ID/
# Note: AKS uses the subdomain "oic" (not "oidc") — this is the correct format.
# For any cluster — the issuer is in the service account token's iss claim:
kubectl get --raw /openid/v1/configuration | jq .issuer
# → https://kubernetes.default.svc.cluster.local (for kind)
# → https://kubernetes.default.svc (for kubeadm)
Step 3 — Configure AIControls to validate SA tokens
# values.yaml (Helm)
identity:
validationMode: oidc
clusterOIDCIssuer: "https://oidc.eks.us-east-1.amazonaws.com/id/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
clusterOIDCAudience: "aicontrols" # must match audience in projected SA tokens; required when clusterOIDCIssuer is set
jwksCacheTTLSeconds: 300
agentCacheTTLSeconds: 60
Step 4 — Project a short-lived token into agent pods
The agent pod requests a projected ServiceAccount token bound to the aicontrols audience with a 1-hour TTL. Kubernetes rotates it automatically before expiry.
# agents/claude-code-agent-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: claude-code-agent
namespace: agents
spec:
template:
spec:
serviceAccountName: claude-code-agent
volumes:
- name: aicontrols-token
projected:
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3600 # 1-hour TTL; auto-rotated
audience: aicontrols
containers:
- name: agent
image: ghcr.io/your-org/claude-code-agent:latest
env:
- name: MCP_SERVER_URL
value: "http://aicontrols.aicontrols.svc.cluster.local:8080"
- name: AICONTROLS_AGENT_TOKEN_PATH
value: "/var/run/aicontrols/token"
volumeMounts:
- name: aicontrols-token
mountPath: /var/run/aicontrols
readOnly: true
Step 5 — Enforce unique SAs with Kyverno
This policy blocks any agent pod that uses the default ServiceAccount, ensuring every agent has a traceable identity.
# kyverno/require-dedicated-agent-sa.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-dedicated-agent-sa
spec:
validationFailureAction: Enforce
background: false
rules:
- name: deny-default-sa
match:
any:
- resources:
kinds: [Pod]
namespaceSelector:
matchLabels:
aicontrols.nirmata.io/govern-agents: "true"
validate:
message: "Agent pods must use a dedicated ServiceAccount — not 'default'."
deny:
conditions:
any:
- key: "{{request.object.spec.serviceAccountName}}"
operator: AnyIn
value: ["default", ""]
Apply it:
kubectl apply -f kyverno/require-dedicated-agent-sa.yaml
Step 6 — Write per-agent CEL policies
With identity resolved, you can write policies scoped to a specific agent:
# policy: claude-code-agent-tool-allowlist.yaml
apiVersion: nirmata.io/v1
kind: AIPolicy
metadata:
name: claude-code-read-only
spec:
matchConditions:
- expression: |
object.mcp.tool != "" &&
object.agent.serviceAccount == "claude-code-agent"
validations:
- expression: |
object.mcp.tool in [
"read_file", "list_directory", "search_files",
"get_file_contents", "search_code"
]
message: "claude-code-agent is restricted to read-only file tools."
action: Deny
---
# Separate budget per agent class
apiVersion: nirmata.io/v1
kind: AIPolicy
metadata:
name: ci-agent-daily-budget
spec:
matchConditions:
- expression: |
object.llm.model != "" &&
object.agent.serviceAccount == "ci-agent"
validations:
- expression: object.session.percentUsed < 100.0
message: "CI agent daily token budget exhausted."
action: Deny
Every agent SA automatically gets a row in Admin UI → Identities showing its deny rate, session count, risk score, and behavioral baseline. No extra configuration needed.
Scenario 2: Dual identity — agent + delegating user
When a human user launches an agent that acts on their behalf, you need to know both who ran the agent and for whom it ran. Dual identity captures both in every audit event and makes both available in CEL policies.
The problem with agent-only identity
Without dual identity:
| Question | Agent-only answer | Dual identity answer |
|---|---|---|
| Who touched production? | ci-agent | sarah@company.com via ci-agent |
| Can developers run write agents? | Cannot express | object.user.groups check |
| Who to notify for HITL? | Generic admin channel | The developer who triggered the action |
| Compliance: "Show all changes by contractor X" | Not answerable | Filterable by user email |
How it works
AIControls reads two tokens from each request:
| Header | Carries | Resolved as |
|---|---|---|
Authorization: Bearer <token> | Agent's projected SA token | object.agent.* |
X-User-Token: <token> | User's OIDC token or developer PAT | object.user.* |
Both identities appear in every audit event. object.user.isPresent is true only when the user token was successfully validated.
Step 1 — Configure user OIDC providers
Both clusterOIDCIssuer/clusterOIDCAudience (used to validate agent ServiceAccount tokens, Scenario 1 above) and oidcProviders (used to validate the user's token for dual identity) are app settings — see Configuration Model — so values.yaml only seeds them on a fresh install. Manage cluster OIDC going forward from Settings → Identity & Access (or the onboarding flow), and user OIDC providers from Settings → Identity & Access → SSO / OIDC in the Admin UI.
# values.yaml — seeds cluster OIDC and oidcProviders once on first boot only
identity:
validationMode: oidc
clusterOIDCIssuer: "https://oidc.eks.us-east-1.amazonaws.com/id/AAAAAAA"
oidcProviders:
- name: okta
issuer: "https://company.okta.com/oauth2/default"
audience: "aicontrols"
groupsClaim: groups # OIDC claim containing group memberships
- name: google-workspace
issuer: "https://accounts.google.com"
audience: "CLIENT_ID.apps.googleusercontent.com"
hostedDomain: company.com
If you're adding or changing a provider on a cluster that's been running for a while, edit it in Settings → Identity & Access → SSO / OIDC instead of values.yaml — the database already has a value, so values.yaml is no longer consulted for this field.
Step 2 — Pass the user token from the agent
Option A — Developer PAT (easiest for Claude Code users)
Developers create a Personal Access Token in Settings → Personal Access Tokens. The PAT carries both the user's identity and is scoped to their agent session. Set it as the agent's bearer token — no extra header needed.
# In Claude Code's MCP config:
# AICONTROLS_AGENT_TOKEN contains the developer's PAT
export MCP_SERVER_URL=http://aicontrols.aicontrols.svc.cluster.local:8080
Option B — Explicit X-User-Token header
For orchestrator agents that launch sub-agents on a user's behalf, forward the user's OIDC token:
POST /mcp HTTP/1.1
Authorization: Bearer eyJ... (agent SA token)
X-User-Token: eyJ... (user OIDC token from Okta/Google/etc.)
The agent obtains the user's token from the session that triggered it (e.g., from an OAuth flow, from Okta device flow, or from an enterprise SSO cookie).
Step 3 — Write dual-identity policies
# Require platform-engineering group for production write tools
apiVersion: nirmata.io/v1
kind: AIPolicy
metadata:
name: production-writes-require-platform-eng
spec:
matchConditions:
- expression: |
object.mcp.tool in [
"apply_manifest", "delete_resource", "patch_resource",
"scale", "run_command", "write_file"
]
validations:
- expression: |
object.user.isPresent &&
object.user.groups.exists(g, g == "platform-engineering")
message: >
Production write operations require a platform-engineering group member.
Developers outside this group and unauthenticated agents are blocked.
action: Deny
# HITL for any developer-triggered destructive operation
# routes the approval notification to the specific developer
apiVersion: nirmata.io/v1
kind: AIPolicy
metadata:
name: hitl-developer-destructive-ops
spec:
matchConditions:
- expression: |
object.mcp.tool in ["apply_manifest", "delete_resource", "patch_resource"] &&
object.user.isPresent &&
!object.user.email.endsWith("@ci.internal")
action: Approve
approvalConfig:
timeoutMinutes: 30
timeoutAction: deny
channels: ["slack", "email"]
message: >
{{object.user.email}} is requesting {{object.mcp.tool}} via
{{object.agent.serviceAccount}}. Approve or deny within 30 minutes.
# Deny agents acting without user context in production
apiVersion: nirmata.io/v1
kind: AIPolicy
metadata:
name: require-user-context-in-production
spec:
matchConditions:
- expression: |
object.mcp.tool != "" &&
object.agent.namespace == "production-agents"
validations:
- expression: object.user.isPresent
message: "Agents in production-agents namespace must carry a validated user context."
action: Deny
CEL identity fields reference
| Field | Type | Description | Example |
|---|---|---|---|
object.agent.serviceAccount | string | K8s ServiceAccount name | claude-code-agent |
object.agent.namespace | string | Pod namespace | agents |
object.agent.denyRate24h | float | Fraction of calls denied in last 24h | 0.12 |
object.agent.baselineZScore | float | Deviation from behavioral baseline | 2.1 |
object.user.isPresent | bool | User token was validated | true |
object.user.email | string | User email from OIDC claim | sarah@company.com |
object.user.groups | list | Group memberships from OIDC claim | ["platform-engineering"] |
object.session.toolCallCount | int | Tool calls so far this session | 47 |
object.session.deniedInSession | int | Denied calls this session | 2 |
object.session.percentUsed | float | Budget consumed (0–100) | 62.4 |
object.mcp.tool | string | Tool name; empty for LLM calls | read_file |
object.llm.model | string | Model ID; empty for MCP calls | claude-sonnet-4-6 |
See Policy Context Reference for all available fields, their types, and example values.
Scenario 3: Agent identity for network egress calls
Network egress filtering governs the raw HTTP(S) calls an agent process makes directly — a curl, a kubectl command, a webhook — outside the MCP protocol entirely. Those calls have no MCP envelope to carry a custom header in, so the Authorization/X-Aicontrols-Agent-Token mechanism from Scenarios 1 and 2 doesn't apply: a kubectl or gh process can't be told to set an arbitrary header, and often there's no agent-side code to modify at all.
The problem with header-based identity for egress
An egress call is just whatever HTTP(S) request the agent process happens to make — there's no client library in the loop that AIControls controls or that the agent developer necessarily wrote. Requiring a custom header would mean patching every tool an agent might shell out to.
How it works
The egress proxy is reached as an HTTP(S) proxy (HTTP_PROXY/HTTPS_PROXY), so it identifies callers using the credential every proxy-aware HTTP client already knows how to send: Proxy-Authorization (RFC 7235), populated automatically when the proxy URL includes a userinfo component. Embed the agent's token there instead of in a custom header:
env:
- name: HTTP_PROXY
value: "http://$(AGENT_TOKEN)@aicontrols.aicontrols.svc.cluster.local:8443"
- name: HTTPS_PROXY
value: "http://$(AGENT_TOKEN)@aicontrols.aicontrols.svc.cluster.local:8443"
From that point on, every request the agent process sends through the proxy carries the credential without any code change: once per TCP connection via the CONNECT request for HTTPS, and on each individual request for plain HTTP. curl, kubectl (a standard Go net/http client), gh, and effectively any HTTP client that honors the proxy environment variables all pick this up for free.
AIControls resolves the Proxy-Authorization credential exactly the way it resolves X-Aicontrols-Agent-Token on the MCP/LLM planes — the same VirtualKey/PAT/ServiceAccount-token lookup — and populates the same object.agent.* CEL fields (serviceAccount, namespace, podName, podUID, nodeName, and so on), so the per-agent policies from Scenario 1 apply unchanged to egress traffic. If a request carries both an explicit X-Aicontrols-Agent-Token header and a Proxy-Authorization credential, the header takes precedence.
Step — Write a policy scoped by agent and destination
# Allow the CI agent to reach GitHub and npm, deny everything else,
# without affecting any other agent's egress
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: ci-agent-egress-allowlist
spec:
matchConditions:
- expression: |
object.http.host != "" &&
object.agent.serviceAccount == "ci-agent"
validations:
- expression: |
object.http.host in ["api.github.com", "registry.npmjs.org"]
message: "ci-agent egress is restricted to GitHub and npm."
validationActions: [Deny]
See Enable Network Egress Filtering for the full setup steps, and Policy Context — object.http.* for every egress-call field.
Requiring verified identity
Both scenarios above produce a verified identity: the ServiceAccount JWT is checked against the cluster's OIDC issuer, and a VirtualKey is resolved via hash lookup. Identities built any other way — anonymous access, or unsigned claims parsed under validationMode: passthrough — are unverified.
By default, unverified identities are allowed through like any other caller. Set identity.requireVerifiedIdentity: true to hard-block them instead: every request from an unverified identity is denied and recorded in the audit log with Policy: unverified-identity.
# values.yaml
identity:
validationMode: oidc
clusterOIDCIssuer: "https://oidc.eks.us-east-1.amazonaws.com/id/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
clusterOIDCAudience: "aicontrols"
requireVerifiedIdentity: true
This is a startup-time setting — it takes effect on the next pod restart (helm upgrade or a rollout restart), not immediately. There is no interactive toggle for it in the admin UI; About shows a read-only "Verified Identity Required" status reflecting the current config value.
requireVerifiedIdentity is validated against the rest of your identity config at startup:
| Combined with | Result |
|---|---|
validationMode: none | Startup error. none mode never cryptographically verifies anyone, so every request would be blocked — the proxy refuses to start. |
allowAnonymous: true | Startup error. allowAnonymous substitutes an unverified synthetic identity for callers with no Authorization header, which requireVerifiedIdentity would then reject — the two settings are contradictory. |
validationMode: passthrough (alias cel) | Startup warning, not an error. Passthrough never checks JWT signatures, so almost all JWT-based callers will be blocked; only VirtualKey, PAT, and session-token callers verify successfully in this mode. |
Before enabling the hard block, enable the audit-mode builtin policy require-agent-identity from Policies → Library to see how many current requests would be affected — it evaluates the same object.identity.verified condition but only flags, never denies.
Next steps
- Write a policy — CEL policy authoring guide
- Enable Network Egress Filtering — set up the egress proxy and agent identity for raw HTTP(S) calls
- Policy Context Reference — full list of CEL fields