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.
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]
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.