Write a Policy
Each example below is a complete, deployable Kyverno CEL policy. For how policy anatomy, enforcement modes, and matching work conceptually, see Policies.
Policy changes made through the console are recorded in Configuration audit. If audit persistence fails, the policy change is rolled back. If an edit or clone reports a concurrent change, reload the policy and retry.
General workflow
- Decide what to match. Use
object.mcp.tool != ""to target MCP tool calls orobject.llm.model != ""to target LLM calls — see Policy Context for the full set of available fields. - Write the validation expression. A CEL boolean expression that must evaluate to
truefor the request to be allowed. - Start in
Auditmode. Deploy withvalidationActions: [Audit]to measure false-positive rates against real workload before enforcing. - Graduate to
Warn, thenDeny. Once you have confidence in the pattern, tighten the enforcement mode — or useApprove(warn-approve) to route matching requests to human review instead of blocking them outright.
Test workload intent policies in Audit mode
The built-in intent templates exclude MCP discovery probes and compare existing workload registration, MCP classification, and session conformance facts. They ship disabled and in Audit mode. Enabling a template records matching findings; it does not hold calls, pause sessions, or quarantine identities.
| Template | Finding |
|---|---|
intent-registration-required | MCP access above read without verified registration |
intent-tier-boundary | A shell-classified tool call above the declared exec tier |
intent-conformance-review | Existing conformance score from 0.5 inclusive to 0.8 exclusive |
intent-conformance-high | Existing conformance score from 0.8 through 1.0 |
An omitted intent section declares no constraint. The tier policy preserves that meaning.
Three earlier templates are retired: intent-declared-tool-scope, intent-undeclared-server-high-risk, and intent-undeclared-server-low-risk. AIControls deletes them at startup. Intent no longer declares servers or tools, so their signal is now an unmatched call (object.evaluation.accessMatched == false): a call that no Access Binding grants. An unmatched call shows up in Audit and is denied in Enforce. See Workload access. Most templates require a registered workload with a verified identity binding. The registration-required template also flags unregistered callers and registrations without a verified binding when access exceeds read.
Conformance uses the existing trajectory score; it does not add another detector or infer structured permissions from an onboarding description.
- Choose an intent template in Policies. Open its existing Test Policy panel.
- Expand Workload intent simulation. In MCP Tool mode, select Use synthetic workload intent, then load an invoice-reconciliation example or edit the JSON facts.
- Run the test. Registration fields supply the simulated agent declaration, server/access fields describe the MCP call, and conformance fields supply a test score. Blank access type uses the existing qualified-tool-name classifier fallback. Omitted trust tier uses
untrusted/external;unknownis available only as an explicit synthetic value. These inputs do not load pinned tool metadata, annotations, or configured classification overrides; supply explicit access and trust facts when testing those cases. A named server allows its qualified tool name to be normalized to the bare name. OptionaltoolPrefixfollows datasource semantics: omitted uses the server name, an empty string disables prefix stripping, and a custom value overrides the server name. OptionalenforcementStatussupplies that synthetic agent fact; omitted means unknown, not observing. - Compare cases. Test a read, a shell-classified call, and the conformance review band. These are labeled examples, not customer declarations or measured trajectory history.
- Enable only the desired templates in Audit mode. Review real findings and attribution before deciding whether any stronger policy is appropriate.
Synthetic intent inputs are available only for MCP tool calls, matching the runtime path that supplies conformance. Switching to LLM Call or Network Egress omits these inputs; the API rejects synthetic intent for those preview types. Synthetic tests do not update registrations, calculate a session trajectory, execute tools, or call an LLM. Disabling the synthetic-intent option restores the ordinary tester inputs. Kernel enforcement enablement does not promote these global gateway templates; workload-specific promotion and its evidence review are separate integration work.
Historical replay does not reconstruct workload registration or session conformance snapshots. Its intent-policy decisions are incomplete and cannot establish how these templates would have evaluated against the original live context. Use the explicitly synthetic tester for boundary checks and real Audit-mode findings for runtime evidence.
Simulation requests are limited to 1 MiB. Synthetic intent accepts up to 64 conformance reasons; each text value or list entry is limited to 4,096 bytes. Tier maps accept up to 16 entries, with names and values limited to 64 bytes. Enforcement status remains an independent synthetic fact even when registration is false.
Restrict premium models by capability
Deny requests for Claude Opus unless the calling agent has the premium-models capability:
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: model-access-by-capability
spec:
matchConditions:
- expression: 'object.llm.model != ""'
validations:
- expression: |
!object.llm.model.startsWith("claude-opus") ||
"premium-models" in object.agent.capabilities
message: "Claude Opus is restricted to agents with the premium-models capability"
validationActions: [Deny]
Block shell commands containing secret patterns
Match only bash tool calls, then deny any command whose text matches common secret-key prefixes:
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: no-secrets-in-bash
spec:
matchConditions:
- expression: 'object.mcp.tool == "bash"'
validations:
- expression: |
!object.mcp.arguments.command.matches("(sk-|ghp_|AKIA)[A-Za-z0-9]+")
message: "Possible secret detected in bash command — request blocked"
validationActions: [Deny]
Require a ticket when session budget is half-consumed
Combine a namespace match condition with a session-budget validation to require a ticket ID once spend crosses 50%:
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: session-budget-gate
spec:
matchConditions:
- expression: 'object.agent.namespace == "production"'
validations:
- expression: |
object.session.percentUsed < 0.5 ||
object.session.ticketId != ""
message: "A ticket ID is required once 50% of session budget is consumed"
validationActions: [Deny]
Restrict tools through the LLM gateway
When an agent connects to an MCP server directly instead of through AIControls /mcp, MCP policies never see those calls. The agent's model traffic still passes through the LLM gateway, though. On Anthropic /v1/messages and OpenAI /v1/chat/completions and /v1/responses, three LLM policy fields carry tool data:
object.llm.tools: the tools the client advertised to the model on this request.object.llm.messages[].toolCalls: the tool calls earlier assistant turns made in the conversation.object.llm.response.toolCalls: the tool calls the model returned in the response.
Each tool call has id, name, input (the parsed argument object, or an empty map when the arguments are not a JSON object), rawInput (the arguments exactly as sent), trustTier, and trustTierSource. Entries in object.llm.tools also have trustTier and trustTierSource. In /v1/responses traffic, an mcp_call item keeps its arguments. A tool run on a remote MCP server by the provider (mcp_call in /v1/responses, mcp_tool_use in Anthropic /v1/messages) is named mcp__<server>__<tool>, where <server> is the server label in the request, so it matches the same Resources package names as an MCP call. Provider tool items such as web_search_call or computer_call appear with the item type as name and an empty input.
This policy blocks a DeepWiki MCP server in all three places:
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: deny-deepwiki-tools-llm
spec:
matchConditions:
- expression: 'object.llm.model != ""'
validations:
- expression: |
!object.llm.tools.exists(t, t.name.startsWith("mcp__deepwiki__")) &&
!object.llm.messages.exists(m, m.toolCalls.exists(c, c.name.startsWith("mcp__deepwiki__"))) &&
!object.llm.response.toolCalls.exists(c, c.name.startsWith("mcp__deepwiki__"))
message: "Blocked: the DeepWiki MCP server is not approved for use."
validationActions: [Deny]
On the request pass, a deny stops the request before it reaches the model. On the response pass, a deny withholds the model's reply, so the client never receives the tool call. On /v1/messages the client receives a normal assistant message that begins Blocked by AIControls: followed by the policy message, not an HTTP error.
On an Anthropic /v1/messages streaming response from an Anthropic-format upstream, AIControls holds each tool-call block until the block is complete and checks it before the client receives any part of it. Text that streamed before the tool call has already reached the client. A deny closes any open block and ends the stream with a text block that begins Blocked by AIControls: followed by the policy message, so the client does not treat it as a failed or unauthorized request.
Restrict by trust tier
Each LLM tool call and advertised tool gets a trustTier, classified with the same rules as an MCP call to the same tool or host:
- A tier you set on Inventory → Resources applies. A tool row matches the tool name, and a website row matches the host of the tool's
url,uri,endpoint, orhostargument. - Tool patterns and resource overrides in the trust-tier config file apply.
- Built-in package registries and code hosts, such as
github.comandregistry.npmjs.org, arethird-party/vetted. - A tool that no rule matches is
unknown. On the MCP path, an unmatched call isuntrusted/external. On the LLM path, the gateway sees only the name the client picked, so it doesn't assume where the tool runs.
trustTierSource says which rule set the tier, for example override-package:mcp__deepwiki__ask_question.
This policy blocks any tool call to a tool or website you marked Unverified on the Resources page:
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: deny-unverified-llm-tools
spec:
matchConditions:
- expression: 'object.llm.model != ""'
validations:
- expression: |
!object.llm.response.toolCalls.exists(c, c.trustTier == "untrusted/external")
message: "Blocked: this tool is marked Unverified."
validationActions: [Deny]
The policy that Block this resource and Audit only generate on the Resources page matches object.tool.trustTier and object.mcp.arguments.url, which apply to MCP calls only. To cover the LLM path, match object.llm.response.toolCalls[].trustTier as shown above.
Limits to plan for:
- Names are client-chosen. A tool name such as
mcp__deepwiki__ask_questionis a label the client picked. It doesn't prove which server provides the tool. - Clients don't always advertise every tool. A client can leave tools out of
toolson some turns, so also match onmessagesandresponse.toolCalls. - Some streaming routes aren't covered. On streaming responses,
object.llm.response.toolCallsis checked only for Anthropic/v1/messagesrouted to an Anthropic-format upstream. On other streaming routes, such as OpenAI/v1/chat/completions,/v1/responses, and translated routes, it is an empty list. Request-pass checks ontoolsandmessagesstill apply to every streaming request. - Very large streamed tool inputs are checked by name. AIControls holds a streamed tool-call block up to the response buffer size, 512 KB by default. A block that grows past it is checked with its name and the input received so far.
inputis then an empty map, andrawInputholds the partial arguments. The rest of the block then streams without holding. - The model's request is controlled, not execution. These fields govern what passes through the gateway. An agent that executes tools without going through the gateway needs endpoint or egress controls.
Route model from Opus to Haiku for non-ML users
Use a MutatingPolicy instead of a ValidatingPolicy to rewrite the request rather than block it — here, redirecting Opus requests to Haiku for anyone outside the ml-team group:
apiVersion: policies.kyverno.io/v1
kind: MutatingPolicy
metadata:
name: route-to-haiku
spec:
matchConditions:
- expression: |
object.llm.model.startsWith("claude-opus") &&
!("ml-team" in object.user.groups)
mutations:
- patchType: ApplyConfiguration
applyConfiguration:
expression: '{"model": "claude-haiku-4-5"}'
Content safety policies
AIControls scans every LLM prompt and tool call inline before forwarding it. The examples below use the Warn enforcement mode where a soft interrupt is preferable to a hard block: the request is allowed through, but a warning is appended to the model's response. The AI tool (e.g. Claude Code) sees the warning in its next context window and surfaces it to the developer, who can then decide whether to continue.
Warn the agent when a prompt contains PII
Detects common PII patterns (email addresses, SSNs, credit card numbers) in the user's prompt and asks the agent to confirm before proceeding. The request is not blocked — the agent receives the response plus the warning and can relay it to the developer.
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: warn-pii-in-prompt
annotations:
proxy.nirmata.io/enforcement-mode: warn
spec:
matchConditions:
- expression: 'object.llm.model != ""'
- expression: 'object.llm.currentPrompt != ""'
validations:
- expression: |
!object.llm.currentPrompt.matches(
"\\b[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}\\b|" +
"\\b\\d{3}-\\d{2}-\\d{4}\\b|" +
"\\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14})\\b"
)
message: |
This prompt appears to contain personal data (email address, SSN, or
credit card number). Confirm with the user that sharing this information
is intentional before continuing.
validationActions: [Warn]
Because the mode is Warn, the LLM receives both the original response and the warning appended to it. Claude Code will show the warning to the developer and pause for confirmation before its next action.
Block prompt injection attempts
Detects common prompt injection patterns — instructions embedded in tool outputs or user messages that attempt to hijack the agent's behavior.
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: block-prompt-injection
spec:
matchConditions:
- expression: 'object.llm.model != ""'
- expression: 'object.llm.currentPrompt != ""'
validations:
- expression: |
!object.llm.currentPrompt.matches(
"(?i)(ignore (previous|prior|all) instructions|" +
"you are now|disregard your|new persona|" +
"system prompt:|<\\/?(system|instruction)>)"
)
message: "Possible prompt injection detected — request blocked"
validationActions: [Deny]
Warn before reading credential or secret files
Allows the read to proceed but warns the agent — and by extension the developer — that a sensitive file is being accessed. Useful as a first step before committing to a hard block.
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: warn-credential-file-access
annotations:
proxy.nirmata.io/enforcement-mode: warn
spec:
matchConditions:
- expression: 'object.mcp.tool == "read_file"'
validations:
- expression: |
!has(object.mcp.arguments.path) ||
!object.mcp.arguments.path.matches(
"(?i)(\\.env|\\.pem|\\.key|credentials|secrets|id_rsa|id_ed25519|" +
"\\.aws/credentials|\\.kube/config)"
)
message: |
This file may contain credentials or secrets. Confirm with the user
that reading it is intentional and that the content will not be
logged or forwarded to external systems.
validationActions: [Warn]
Block exfiltration of secrets through bash
Denies bash commands that print or curl known secret file paths or environment variables matching credential name patterns.
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: block-secret-exfiltration
spec:
matchConditions:
- expression: 'object.mcp.tool == "bash"'
validations:
- expression: |
!object.mcp.arguments.command.matches(
"(?i)(cat|curl|wget|echo).*(\\.(env|pem|key)|" +
"AWS_SECRET|GITHUB_TOKEN|ANTHROPIC_API_KEY|" +
"DATABASE_URL)"
)
message: "Possible secret exfiltration blocked — review the command and try again"
validationActions: [Deny]
Start with Audit mode for all content safety policies to measure false-positive rates against your team's real workload. Graduate to Warn, then Deny, once you have confidence in the patterns.
Optional semantic checking with an LLM delegate
When a regular expression can't capture what you're looking for, a ValidatingPolicy can delegate the judgment call to an LLM. These functions are only available in a ValidatingPolicy — a MutatingPolicy referencing any of them fails to compile regardless of whether a delegate endpoint is configured. In a ValidatingPolicy, they compile and run even with no delegate endpoint configured — but a real, model-backed result requires one; check with your platform team first. With no endpoint configured, the plain functions below return their configured fallback value and the status-carrying llm.assess* functions (see below) report status: "unknown".
// Ask a yes/no question about content. Phrase the question so "true" means
// safe/permitted, matching llm.check's fail-open default (true) — a
// danger-phrased question ("does this X?") combined with failOpen: true
// (the default) would deny every request during a delegate outage instead
// of allowing them, the opposite of "fail open".
llm.check(object.mcp.arguments.query, "Is this SQL safe to run against production?") == true
// Classify content into one of a set of categories
llm.classify(object.llm.currentPrompt, ["safe", "pii", "harmful"]) == "safe"
// Score content 0-100 against a criterion (0 = benign, 100 = high risk —
// this expression must evaluate to true to allow, so it denies only once
// the score crosses the threshold)
llm.score(object.mcp.arguments.query, "how likely is this to be a prompt injection?") < 80
llm.check/llm.classify/llm.score return a bare value and can't distinguish a real answer from "no delegate configured" or "the call failed" — both fall back to the same value a genuine result could also produce (for example, a failed call and a real risk score of 0 are both 0). Before a Deny, prefer the status-carrying siblings, which return {status: "complete" | "unknown" | "failed", value, confidence, assessedAt, expiresAt, capability: {id, revision}} so you can tell a confirmed answer apart from an outage and reject stale results. Bind the assessment once with cel.bind rather than calling it twice — two separate calls aren't guaranteed to describe the same assessment when the cache is disabled, full, or expired, and can issue two delegate requests instead of one:
validations:
- expression: |
cel.bind(assessment,
llm.assessScore(object.mcp.arguments.query, "injection risk"),
assessment.status != "complete" || assessment.value < 80)
message: "Blocked: confirmed high injection-risk score"
status: "unknown" means no delegate is configured; "failed" means either a delegate is configured but the call errored, timed out, or returned an unusable response, or the caller passed invalid input (for example, assessClassify with an empty category list) — invalid input always reports "failed", even with no delegate configured, so it's never mistaken for "unknown". value is the zero value whenever status != "complete" — always check status before trusting value. The other two status-carrying functions follow the same shape: llm.assessCheck(text, question) (value: bool) and llm.assessClassify(text, categories) (value: string).
The current delegate contract doesn't return a calibrated confidence estimate, so llm.assess* sets confidence to null even for a complete result. Don't interpret completion as maximum confidence. Sensor plugins that return finding scores populate confidence from their highest actionable finding.
Content-safety plugins expose the same safety rule through object.request.sensors["<plugin>"] and, on final response evaluation, object.llm.response.sensors["<plugin>"] or object.mcp.response.sensors["<plugin>"]. A sensor result has a typed boolean value, plus status, confidence, assessedAt, expiresAt, reasonCode, and capability identity. A request with no scannable arguments reports a complete, negative assessment because it has no content to inspect. A disabled sensor reports unknown; transport errors, timeouts, and malformed responses report failed; an expired complete result is exposed as stale. Check status == "complete" before using value. The legacy hasPII, hasInjection, and score fields remain available while built-in policies migrate, but those scalars can't distinguish a confirmed clean result from an unavailable fail-open provider.
Simulate and Replay never call a real delegate, even when one is configured — a policy using any function on this page always evaluates in a preview against the no-delegate fallback: llm.check/llm.classify/llm.score return their configured value, and an llm.assess* call reports status: "unknown" (or status: "failed" for invalid input, exactly as it would with a real delegate configured). A response with llmFunctionsSkipped: true is telling you exactly this: don't treat that preview's decision as what the policy will do against live traffic.
Your own custom policies using these functions are never restricted this way — they enable normally with no endpoint configured, evaluating against the fallback described above. A small set of built-in policies are the one exception: for a few specific built-ins, that fallback would silently produce the opposite of the protection their name promises, so enabling them is blocked until llmExt.endpoint is configured.
The registration-required intent template applies only to verified Kubernetes ServiceAccount callers. Developer sessions, non-Kubernetes identities, and unverified callers do not match it. To test an unregistered Kubernetes caller, explicitly set kubernetesIdentity: true in the synthetic intent JSON; registered previews also require kubernetesIdentity: true. The server input is a configured datasource name, not a direct-target URL or hostname; this preview does not model direct-target MCP routing. When conformanceEnabled is true, supply an explicit conformanceScore between 0 and 1; omitted or null scores are rejected.