Policy Context
Every CEL expression evaluates against an object map pre-populated from the request. This page lists every available field, its type, and what it contains.
This page's schema-backed sections below are generated directly from the policy engine's field registry and kept in sync automatically — a policy referencing an undeclared field, or misspelling one within a registered namespace, fails to save. A few namespaces further down (object.identity, object.user, object.context, object.http, object.tokenIssuance) are not yet registered there and are maintained by hand until they are.
Available context
Every CEL expression evaluates against an object map pre-populated from the request. The fields are grouped by namespace below. Use object.mcp.tool != "" to target MCP tool calls, object.llm.model != "" to target LLM calls, and object.http.host != "" to target network egress calls — each is empty for the other two call types.
object.mcp is an alias for object.tool — both names route to the same fields in the activation map. This page documents them under object.mcp for tool-call fields; object.tool.datasource, for example, is the same field as object.mcp.datasource.
These namespaces are registered in the policy engine's field registry — a policy referencing a field outside them, or misspelling one within them, fails to save. Grouped by the tag that owns each field (one tag per module).
object.agent
| Field | Type | Description |
|---|---|---|
object.agent.agentId | string | Unique agent or developer identifier (email or agent ID) |
object.agent.autonomyLevel | int | Numeric tier, 1–4 — use for range checks like autonomyLevel >= 3 |
object.agent.autonomyTier | string | CSA NIST autonomy tier, "T1"–"T4". Unassigned agents resolve to "T2" |
object.agent.capabilities | list(string) | Granted capability tags, e.g. "premium-models" |
object.agent.client | string | AI client that originated the call, e.g. "cursor" for Cursor hook-eval calls; empty for standard MCP/LLM proxy paths. |
object.agent.clientId | string | Cross-App Access DCR client_id differentiating one governed agent from others sharing the same delegating human (issue #1880). Empty for non-XAA identities. |
object.agent.labels | map(string, dyn) | Custom agent metadata, e.g. cost center or environment tag |
object.agent.namespace | string | Kubernetes namespace of the agent's service account |
object.agent.nodeName | string | Kubernetes Node name the calling Pod is scheduled on |
object.agent.podName | string | Kubernetes Pod name, from the projected ServiceAccount token's kubernetes.io claim (K8s 1.20+) |
object.agent.podUID | string | Kubernetes Pod UID, same claim as podName |
object.agent.quarantined | bool | true if the agent is suspended |
object.agent.tokenClaims | map(string, dyn) | All raw JWT claims — useful for custom IdP attributes |
object.exceptions
| Field | Type | Description |
|---|---|---|
object.exceptions | list(dyn) | Escape-hatch read-only list of active granted exceptions applying to this call (§7) |
object.llm
| Field | Type | Description |
|---|---|---|
object.llm.currentPrompt | string | Text of the last user message |
object.llm.inputTokens | int | Input token count for this request (0 if not yet known) |
object.llm.messages | list(dyn) | Full conversation history — each item has role and content |
object.llm.model | string | Requested model identifier, e.g. claude-sonnet-4-6. Empty for MCP calls. |
object.llm.response | dyn | LLM response projection on the response pass, including usage/token counts (§3.6) |
object.llm.response.credentialEntities | list(string) | Credential entity codes found in the LLM response, deduplicated |
object.llm.response.findings | list(dyn) | Per-finding spans detected in the LLM response: list of {class, entity, severity, score, start, end}. start/end are byte offsets targeted by patchType:Redaction. Empty for unbuffered streaming responses. |
object.llm.response.hasCredential | bool | True when a credential or secret was detected in the LLM response |
object.llm.response.hasInjection | bool | True when a prompt-injection pattern was detected in the LLM response |
object.llm.response.hasPII | bool | True when any PII entity was detected in the LLM response |
object.llm.response.hasUnsafeContent | bool | True when an unsafe-content finding was reported in the LLM response |
object.llm.response.injectionScore | double | Highest confidence score among injection findings in the LLM response (0.0-1.0) |
object.llm.response.isFinal | bool | True on the buffered-final response pass (full text assembled); false on per-chunk streaming passes and the request pass. |
object.llm.response.piiConfidence | double | Highest confidence score among PII findings in the LLM response (0.0-1.0) |
object.llm.response.text | string | LLM response text on the response pass; empty on the request pass. On an unbuffered per-chunk streaming pass this is only the current chunk, not the full reply — a policy that needs the complete message (not just this delta) must gate on llm.response.isFinal, which for a streaming call only becomes true when llmProxy.bufferForDLP: true buffers the full response before evaluating. |
object.llm.response.unsafeEntities | list(string) | Unsafe-content entity labels found in the LLM response, deduplicated |
object.llm.tier | string | Cost tier: "premium", "standard", or "economy" |
object.llm.upstream.jurisdiction | string | Data residency region, e.g. "EU", "US" |
object.llm.upstream.pool | string | Upstream Pool (#1089 Phase 1, Preview) this request's upstream belongs to, empty when no pool applies. Reflects the primary member resolved pre-dial — which member ultimately served the request after any failover is audit-only, not CEL-visible, since policy evaluates once before any dial happens. |
object.llm.upstream.zeroDataRetention | bool | Upstream provider guarantees zero data retention |
object.mcp
| Field | Type | Description |
|---|---|---|
object.mcp.arguments | map(string, dyn) | Tool arguments |
object.mcp.command | string | Shell command text (shell tools only) |
object.mcp.commandRisk | string | Pre-classified risk: "risky", "moderate", or "safe" |
object.mcp.method | string | MCP JSON-RPC method name, e.g. "tools/call", "server/discover", "subscriptions/listen" (MCP 2026-07-28 spec support). Defaults to "tools/call" for an ordinary tool call. Empty for LLM calls. |
object.mcp.name | string | Alias of object.mcp.tool / object.tool.name — the agent-visible MCP tool name (object.mcp.* mirrors object.tool.*). Empty for LLM calls. |
object.mcp.params.notifications | map(string, dyn) | subscriptions/listen's requested notification subscriptions, e.g. {toolsListChanged: true, resourceSubscriptions: ["file:///foo"]}. Absent for tools/call and other methods. |
object.mcp.response | dyn | MCP tool response projection on the response pass (§3.6) |
object.mcp.response.credentialEntities | list(string) | Credential entity codes found in the MCP tool response, deduplicated |
object.mcp.response.findings | list(dyn) | Per-finding spans detected in the MCP tool response: list of {class, entity, severity, score, start, end}. start/end are byte offsets targeted by patchType:Redaction. Empty for unbuffered streaming responses. |
object.mcp.response.hasCredential | bool | True when a credential or secret was detected in the MCP tool response |
object.mcp.response.hasInjection | bool | True when a prompt-injection pattern was detected in the MCP tool response |
object.mcp.response.hasPII | bool | True when any PII entity was detected in the MCP tool response |
object.mcp.response.hasUnsafeContent | bool | True when an unsafe-content finding was reported in the MCP tool response |
object.mcp.response.injectionScore | double | Highest confidence score among injection findings in the MCP tool response (0.0-1.0) |
object.mcp.response.isFinal | bool | True on the buffered-final response pass (full text assembled); false on per-chunk streaming passes and the request pass. |
object.mcp.response.piiConfidence | double | Highest confidence score among PII findings in the MCP tool response (0.0-1.0) |
object.mcp.response.text | string | MCP tool response text on the response pass; empty on the request pass. On an unbuffered per-chunk streaming pass this is only the current chunk, not the full response — gate on mcp.response.isFinal to evaluate the fully assembled text. |
object.mcp.response.unsafeEntities | list(string) | Unsafe-content entity labels found in the MCP tool response, deduplicated |
object.mcp.ssrf.hasPrivateIP | bool | true if a URL argument resolved to a private or internal address |
object.mcp.ssrf.hostnames | list(string) | Parsed hostname of every URL argument found |
object.mcp.ssrf.resolvedIPs | list(string) | Every IP address resolved from URL arguments in this call |
object.mcp.tool | string | MCP tool name, e.g. bash, read_file. Empty for LLM calls. |
object.mcp.trustTier | string | MCP server trust: "trusted/internal", "trusted/verified", "untrusted/external" |
object.request
| Field | Type | Description |
|---|---|---|
object.request.credentialEntities | list(string) | Credential entity codes found (e.g. PRIVATE_KEY, AWS_KEY, CONNECTION_STRING), deduplicated |
object.request.hasCredential | bool | True when a credential or secret was detected |
object.request.hasInjection | bool | True when a prompt-injection pattern was detected |
object.request.hasPII | bool | True when any PII entity was detected in the scanned content |
object.request.hasUnsafeContent | bool | True when an unsafe-content finding was reported |
object.request.injectionScore | double | Highest confidence score among injection findings (0.0-1.0) |
object.request.piiConfidence | double | Highest confidence score among PII findings (0.0-1.0) |
object.request.unsafeEntities | list(string) | Unsafe-content entity labels, deduplicated |
object.session
| Field | Type | Description |
|---|---|---|
object.session.budgetUSD | double | Allocated cost budget for this session |
object.session.deniedInSession | int | Policy denials already recorded in this session |
object.session.developerId | string | Email of the developer who started the session |
object.session.intent | string | Declared session purpose |
object.session.isPresent | bool | true if a session context is active |
object.session.percentUsed | double | Fraction of budget consumed (0.0-1.0) |
object.session.spentUSD | double | Cost spent so far in this session |
object.session.ticketId | string | Associated Jira / GitHub issue ID |
object.session.toolCallCount | int | Total tool calls in the session |
object.session.toolsCalledInSession | list(string) | Distinct tools used so far (capped at 200) |
object.skill
| Field | Type | Description |
|---|---|---|
object.skill.allowedTools | list(string) | Frontmatter allowed-tools (experimental) — the skill's own declared tool scope. Empty when not declared. |
object.skill.compatibility | string | Frontmatter compatibility string (environment requirements), if declared. Empty otherwise. |
object.skill.description | string | Frontmatter description (Agent Skills spec). Empty until first observed. |
object.skill.digest | string | Content digest (sha256) of the running skill content. Empty until first observed. |
object.skill.indicators | list(string) | Fired indicator IDs, static + BEHAV_* |
object.skill.license | string | Frontmatter license, if declared. Empty otherwise. |
object.skill.metadata | map(string, string) | Frontmatter metadata map, if declared. Empty otherwise. |
object.skill.name | string | Skill name, e.g. "policy-from-audit". Empty when no skill is active. |
object.skill.resolution | string | "digest", "session", "name", or empty — how riskScore/riskBand were resolved. "digest" is exact-content proof: the running content was hashed in this request. "session" inherited the digest from this session's active-skill window (strong, but the content could have changed since the window opened). "name" is advisory only and always reports scanned=false. |
object.skill.riskBand | string | "trusted", "review", or "blocked"; empty when unscanned |
object.skill.riskScore | int | Combined static + behavioral risk score, 0-100. 0 when unscanned. |
object.skill.scanned | bool | True only when the skill was resolved by content digest (resolution "digest" or "session") AND that content has completed at least one static scan. Name-advisory resolution always reports false (impersonation defense, §9.1). |
object.skill.source | string | "prompt", "uploaded", "marketplace", or "builtin" |
Resolved defaults — object.defaults.*
| Field | Type | Description |
|---|---|---|
object.defaults.budget.limits | list(dyn) | Effective budget limits for the requested model, one entry per matching scope tier, most specific first. Each entry has tier (token, agent, user, group, or global), scope, modelPattern, limitUSD, window (30d, 7d, 24h, 1h), mode (deny, approval, warn, audit), and warnAt (percent, 0-100). Empty/absent for calls without a model. |
object.defaults.compression.matchedOn | string | Which rule selected the mode: trusted-mcp:<pattern>, never-compress:<pattern>, agent-rule:<pattern>, tool-rule:<pattern>, or default — debugging/audit context only. |
object.defaults.compression.mode | string | The effective compression mode for this MCP tool call: off, conservative, standard, aggressive, ml, semantic, or ast. This is the request-time base mode — it does not reflect content-type detection or context-pressure escalation, both computed only on the response pass. Absent for LLM calls and absent when compression is disabled. |
object.defaults.egress.allowedDomains | list(string) | The intersected network-egress allowlist across every scope tier that applies to the caller — exact domains and *.suffix patterns. |
object.defaults.model.allowedModels | list(string) | The caller's effective LLM model allowlist — the org-wide ceiling intersected with their teams' allowlists, unioned with any per-developer overrides, and folded with any granted llm.model-access exceptions. Absent for calls without a model, and absent when the resolved list is unrestricted. |
object.defaults.routing.matchedOn | string | Why routing's static pick was selected: labels, models, groups, wildcard, model-family, or fallback — debugging/audit context only. |
object.defaults.routing.upstreamName | string | The effective upstream name for this call: routing's static pick, or the name a patchType: Defaults policy overrode it to. Absent for calls with no upstream configured (and for MCP tool calls, which have no upstream to route). |
Overriding resolved defaults — patchType: Defaults
A MutatingPolicy with patchType: Defaults can narrow the object.defaults.* values above before validating policies read them — see Defaults mutations for how narrowing and the widening clamp work. routing and compression are the exceptions to "narrow only": there is no ceiling to narrow against, so a patchType: Defaults policy setting routing.upstreamName or compression.mode freely overrides the resolved value. For routing, the request is then forwarded to the named upstream. For compression, the response is then compressed using the overridden mode.
object.defaults.* is populated live and enforced. egress and budget are enforced on both LLM paths (Claude Code / Anthropic-format requests and OpenAI-compatible chat completions) and MCP tool calls. routing and model are LLM-only and absent for MCP tool calls (MCP calls have no model to route or scope). compression is MCP-only and absent for LLM calls (see below). The resolved (and exception-adjusted, clamp-protected) object.defaults.egress.allowedDomains list gates each call's destination (see Restrict Network Egress), object.defaults.budget.limits reflects the effective per-model spend caps enforced against live spend on every call, object.defaults.model.allowedModels gates the requested model (enforced by the built-in team-model-scope policy), and object.defaults.routing.upstreamName — if overridden by a patchType: Defaults policy — determines which upstream the call is actually forwarded to.
compression is the mirror image of routing: it is MCP-only and absent for LLM calls. The resolved (or overridden) object.defaults.compression.mode drives the actual compression of the tool response on the response pass — a patchType: Defaults policy setting compression.mode (for example, the skip-truncation-for-large-non-code-reads pattern) has a real, observable effect on the bytes returned to the caller, not just on what a later policy reads back. Content-type detection and context-pressure escalation still run on top of the resolved/overridden base mode on the response pass, exactly as they do for the unoverridden case — only agent/tool-rule/global-default selection is replaced by the override.
Identity verification — object.identity.*
| Field | Type | Description |
|---|---|---|
object.identity.verified | bool | true only when the identity was cryptographically or credential-verified — a Kubernetes ServiceAccount/OIDC JWT that passed full signature, issuer, and audience validation, or a proxy-issued VirtualKey resolved by hash lookup. false for anonymous, none-mode, passthrough (unsigned claims), and other self-declared identities. |
object.identity.verification_method | string | How verified was established: "k8s-sa", "oidc-user", "virtualkey", "pat", "session", or "" when unverified. |
object.identity.effective_allowed_models | list<string> | The developer's team-resolved model allowlist (global ceiling intersected with team grants), computed BEFORE any granted llm.model-access exception is folded in. Empty = unrestricted. The built-in team-model-scope policy enforces object.defaults.model.allowedModels instead (see the resolved defaults table above) — the exception-folded version of this same list — so a custom policy that needs live exception grants honored should read object.defaults.model.allowedModels, not this field. |
object.identity.pat_allowed_models | list<string> | Per-PAT narrowing of effective_allowed_models. Empty = no additional restriction. |
object.identity.pat_allowed_tools | list<string> | MCP tool names this PAT may call. Empty = unrestricted. |
See Requiring verified identity for the identity.requireVerifiedIdentity config flag that hard-blocks requests where object.identity.verified is false.
Token issuance — object.tokenIssuance.*
Populated only when a policy evaluates a Cross-App Access / ID-JAG token-issuance request — the Authorization Server's grant hook — rather than a proxied tool call or LLM request. This is a separate evaluation point that runs before any token is minted, so object.mcp.* and object.llm.* are not populated here.
| Field | Type | Description |
|---|---|---|
object.tokenIssuance.server | string | Name of the MCP server (datasource) the caller is requesting a token for, resolved from the per-server issuer endpoint. Access Bindings enforce group→server grants at this point: a team with no binding covering this server gets invalid_grant. Empty on the single-issuer /oauth/token endpoint. |
object.tokenIssuance.grantType | string | The OAuth grant type being exchanged, e.g. "urn:ietf:params:oauth:grant-type:jwt-bearer". Use object.tokenIssuance.grantType != "" to scope a policy to the token-issuance evaluation point. |
object.tokenIssuance.requestedScope | string | The space-delimited scope the client requested for the minted access token (empty if none requested). Compare against a binding's allowed scopes to enforce scope narrowing. |
object.tokenIssuance.resource | string | The RFC 8707 resource indicator the client requested the token for (the target MCP server's canonical URI), empty if none supplied. |
Network egress call — object.http.*
Populated for raw outbound HTTP(S) requests intercepted by the network egress filtering proxy — an agent process's own network calls, not MCP tool calls or LLM requests. Empty for both of those; gate egress-only rules with object.http.host != "".
| Field | Type | Description |
|---|---|---|
object.http.host | string | Request host/authority, e.g. "api.github.com". Empty for non-egress calls. |
object.http.method | string | HTTP method, e.g. "GET", "POST" |
object.http.path | string | Request path, e.g. "/repos/octocat/hello-world" |
object.http.query | string | Raw query string, e.g. "tenant=acme&env=prod"; empty when the request has no query. |
object.http.scheme | string | "http" or "https" |
object.http.bodySnippet | string | First few KB of the request body; empty when the body was not buffered |
Delegating user — object.user.*
Populated when a human OIDC token is present alongside the agent token. All fields are empty / false for machine-to-machine calls — guard with object.user.isPresent.
| Field | Type | Description |
|---|---|---|
object.user.isPresent | bool | true if a user token was validated |
object.user.email | string | User email from OIDC token |
object.user.groups | list<string> | OIDC group memberships |
object.user.roles | list<string> | OIDC roles or approved actions |
object.user.tokenClaims | map | Raw user JWT claims for custom IdP attributes |
Context efficiency — object.context.*
Real-time signals from the session anomaly detector, injected at policy evaluation time. All fields default to 0 when monitoring is not enabled.
| Field | Type | Description |
|---|---|---|
object.context.fillPct | float | Fraction of the model's context window currently in use (0.0–1.0) |
object.context.loopScore | float | Structural repetition score across recent outputs (0.0–1.0; higher = more looping) |
object.context.repeatReadCount | int | Times the same file or resource has been read in this session |
object.context.latencyGrowthRatio | float | Current call latency relative to session baseline (>1.0 = degrading) |
object.context.cacheHitRate | float | Fraction of prompt tokens served from cache (0.0–1.0) |
Response redaction — patchType: Redaction
A MutatingPolicy mutation with patchType: Redaction (and proxy.nirmata.io/direction: response) removes the sensitive spans reported in object.mcp.response.findings[] (MCP tool calls) or object.llm.response.findings[] (LLM calls) from the response text — like every other response field, the path depends on the call type; see Available context. On non-streaming responses — MCP tool results and synchronous LLM replies (Anthropic Messages, OpenAI Chat Completions and Responses, Copilot passthrough, and the Chat↔Anthropic / Cursor-BYOK translation paths) — the redacted text is what actually reaches the caller. On streaming responses the spec is computed but not yet spliced into the streamed bytes; see the limitation note below. The mutation expression returns a spec — which finding classes/entities to redact and the replacement string:
mutations:
- patchType: Redaction
applyConfiguration:
expression: |
{"classes": dyn(["PII", "CREDENTIAL"]), "replacement": dyn("[REDACTED]")}
Spec fields (all optional): redactAll (bool — redact every finding regardless of class), classes (list of finding classes to redact, ignored when redactAll is true), entities (further limit to specific entity codes), replacement (substitution string, default [REDACTED]). Wrap each value in dyn(...) — CEL map literals are homogeneously typed, so a mixed-value map (a list plus a string) must use dyn for the values to compile (same convention as patchType: PIITokenize).
Redaction requires the buffered-text contract: a spec is only computed on the final / buffered response pass, where the full reply text and its finding spans are available. An unbuffered per-chunk streaming pass carries no assembled text, so object.mcp.response.findings[] / object.llm.response.findings[] are empty and no spec is produced — enable bufferForDLP to buffer streaming responses for full-text scanning and spec computation.
A matching deny example on the LLM side, gated to LLM calls only:
matchConditions:
- expression: object.llm.model != ""
validations:
- expression: '!object.llm.response.hasCredential'
message: "LLM response contains a credential pattern"
Custom text checks against the raw response
For checks the built-in scanner doesn't cover — a required disclosure phrase, a forbidden pattern, a business-specific compliance rule — use object.llm.response.text (LLM calls) or object.mcp.response.text (MCP tool calls) directly with CEL string operations (.contains(), .matches()). Gate on isFinal so the check runs once against the complete reply rather than on every streaming chunk:
matchConditions:
- expression: object.llm.model != ""
- expression: object.llm.response.isFinal
validations:
- expression: object.llm.response.text.contains("Reply STOP to opt out")
message: "Outbound message is missing the required opt-out disclosure."
object.llm.response.text / object.mcp.response.text is always populated on a response-phase pass — but on an unbuffered per-chunk streaming pass it holds only the current chunk, not the full reply. object.llm.response.isFinal / object.mcp.response.isFinal is true only on the buffered-final pass, where the full reply text has been assembled — a check like the one above, without the isFinal gate, would otherwise evaluate against a small fragment on every chunk and produce noisy, often-wrong results (a required phrase near the end of the message looks "missing" on every chunk until the one that actually contains it).
bufferForDLP: true for a full-text checkFor a non-streaming call, isFinal is always true and text is always the complete reply — no extra configuration needed. For a streaming LLM response, the buffered-final pass (isFinal: true, full assembled text) only happens when llmProxy.bufferForDLP: true is set in config.yaml. Without it, streaming responses never reach a final pass at all — the per-chunk passes keep running (each seeing only its own small chunk and no scanner findings), so an isFinal-gated policy simply never evaluates, and a policy without that gate runs repeatedly against fragments instead. This is the same buffered-text contract that redaction relies on (above) — buffering also holds the response back until the full-text check passes, which is required for a deny to actually block delivery; without it, chunks are already on the wire by the time a full-message violation would be detected.
On streaming responses (including those buffered under bufferForDLP) the response is still evaluated and DLP-scanned — a scan or policy deny blocks the whole stream — and a redaction spec is still computed on the buffered-final pass. But the redacted text is not currently spliced back into the streamed chunks; the SSE deltas are forwarded as received. Response-side redaction therefore reaches the caller only on non-streaming responses (MCP tool results and synchronous LLM replies) today. To guarantee a sensitive span never leaves the proxy on a streamed response, write a policy that denies it rather than redacts it, or have the client issue a non-streaming request.
patchType: MCPResponseThe older patchType: MCPResponse (regex→replacement pairs collected on the request pass and applied to MCP tool responses) is superseded by Redaction. During the deprecation window both are accepted and MCPResponse keeps its exact existing semantics; after the window closes, saving a policy that still uses MCPResponse is rejected with a pointer to Redaction. Migrate MCP response-redaction policies to Redaction.
Time — top-level variable
| Variable | Type | Description |
|---|---|---|
now | timestamp | Current wall-clock time at policy evaluation. Use now.getHours(), now.getDayOfWeek() for business-hours rules. |
Built-in policy library
AIControls ships with a curated library of policies covering common governance scenarios. Enable them from Policies → Library:
| Category | Example policies |
|---|---|
| Security | Block credential patterns in prompts, deny requests with PII in tool arguments |
| Cost | Enforce model tiers by developer group, block Opus for non-engineering roles |
| Compliance | Require audit mode for regulated data namespaces, block data export tools |
| Operations | Rate-limit bash calls per session, require HITL for infrastructure mutations |
| Autonomy Governance | Record every T1 action, audit high-risk tools by T2 agents, monitor T3+ behavior, flag privileged ops by T4 agents for HITL — see Autonomy Tiers |