Policies
Policies are CEL-based rules that decide what happens to every AI request — allow, deny, audit, mutate, or route for human approval. They run in real time before the request reaches the model.
Policy anatomy
Policies are Kyverno CEL YAML documents. Each policy has optional match conditions and one or more validation expressions:
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: block-prod-kubectl
annotations:
proxy.nirmata.io/enforcement-mode: Approve
spec:
matchConditions:
- expression: 'object.mcp.tool == "kubectl"'
- expression: 'object.agent.namespace == "production"'
validations:
- expression: |
now.getHours() >= 8 && now.getHours() < 18 &&
now.getDayOfWeek() in [1, 2, 3, 4, 5]
message: "Production kubectl outside business hours requires approval"
validationActions: [Deny]
Use MutatingPolicy to rewrite arguments before the request is forwarded (for example, redirecting a disallowed model to an approved one).
Defaults mutations
Before policies run, config resolution computes the effective per-request defaults for the caller — budget limits, egress allowlists — and exposes them to every CEL expression as object.defaults.*. A MutatingPolicy mutation with patchType: Defaults adjusts these resolved values for situations the scoped configuration can't express — dynamic conditions like time of day or current budget pressure:
apiVersion: policies.kyverno.io/v1
kind: MutatingPolicy
metadata:
name: tighten-budget-after-hours
spec:
matchConditions:
- expression: 'now.getHours() >= 18 || now.getHours() < 8'
mutations:
- patchType: Defaults
applyConfiguration:
expression: '{"budget": {"limits": object.defaults.budget.limits.map(l, {"tier": l.tier, "scope": l.scope, "modelPattern": l.modelPattern, "limitUSD": l.limitUSD / 2.0, "window": l.window, "mode": l.mode, "warnAt": l.warnAt})}}'
Two rules govern Defaults mutations:
- Narrow only — the widening clamp. A Defaults patch can tighten resolved values (lower a budget limit, drop an egress domain) but never loosen them. Any attempt to widen — raising a limit, deleting a limit entry, downgrading
denytowarn, adding an egress domain outside the resolved allowlist — is clamped back to the resolved value and recorded, while the rest of the patch still applies. - Identity conditions belong in scoped configuration, not policies. A Defaults policy whose match conditions only test who is calling (team, user, agent) is rejected at save time with a pointer to the matching scope tier in Settings — the scoped-rules hierarchy already expresses per-identity defaults declaratively. Combine identity with a dynamic signal (
now,object.session.percentUsed) and the policy is accepted.
routing is the one namespace this doesn't apply to: upstream routing has no "resolved ceiling" to narrow against — it's a single selected upstream, not a limit — so a Defaults patch setting routing.upstreamName is a full override, not a narrowing. For example, redirecting a request that a content-safety scan flagged as containing PII to an upstream provider with a stronger data-retention guarantee:
apiVersion: policies.kyverno.io/v1
kind: MutatingPolicy
metadata:
name: pii-detected-route-to-zdr-upstream
spec:
matchConditions:
- expression: 'object.request.hasPII'
mutations:
- patchType: Defaults
applyConfiguration:
expression: '{"routing": {"upstreamName": "azure-zdr"}}'
The request is forwarded to the named upstream (azure-zdr here) instead of whatever routing would otherwise have selected. If the name doesn't match any configured upstream — a typo, or an upstream that was since removed — the override is silently ignored and the request proceeds to the originally resolved upstream; a misconfigured routing override never blocks a request.
Defaults patches never touch the request itself — arguments, headers, and message content are unaffected; only the object.defaults.* values later policies (and, for routing, the forwarding logic) read are changed.
Defaults patches and object.defaults.* reads are live end-to-end: egress enforcement consumes the resolved object.defaults.egress value on MCP tool calls and both LLM paths (see Restrict Network Egress), budget enforcement consumes the resolved object.defaults.budget.limits value on the same paths (see Set a Budget), and routing — LLM-only, since MCP tool calls have no upstream to route — consumes the resolved (or overridden) object.defaults.routing.upstreamName value on both LLM paths to pick which upstream actually receives the request. The widening clamp actively restores any widened budget/egress field before enforcement runs; routing has no clamp to apply.
Enforcement modes
Set via validationActions or the annotation proxy.nirmata.io/enforcement-mode. The annotation takes priority.
| Mode | Blocks? | Effect |
|---|---|---|
Deny | Yes | Request blocked immediately; developer sees the denial message. |
Approve | Yes (held) | Request is queued for human review and held inline — MCP tool calls and LLM requests alike. It resumes on approval, and stops on rejection or timeout. See Human-in-the-loop. |
Ask | No (asks) | Request is paused; the user confirms inline and the request proceeds. Supports cooldown-period-minutes. |
Warn | No | Request proceeds; a warning is appended to the response. |
Audit | No | Request proceeds; violation is logged. Use to test a policy before enforcing it. |
The annotation value is case-insensitive. Approve and Ask are shorthand for the longer-form warn-approve and warn-ask, which remain valid too.
Legacy annotation values require-approval and warn-confirm are still accepted and behave identically to Approve (warn-approve) and Ask (warn-ask) respectively — existing policies don't need to be updated.
Add a cooldown period to suppress repeated alerts from the same session:
annotations:
proxy.nirmata.io/cooldown-period-minutes: "15"
Changing modes from the UI
Every policy row on the Policies page has an inline mode selector — switch a policy between Audit, Warn, Enforce, and Approval without editing YAML. A mode set from the UI is authoritative: it overrides the spec annotation and is preserved even when built-in policies are refreshed on upgrade. Select multiple policies with the row checkboxes to set the mode, or enable/disable, in bulk.
Replay — preview before enforcing
Before flipping a policy from Audit to Enforce, click Replay to dry-run it against your recent traffic. Replay evaluates the policy over the last 1,000 audit events without affecting anything live, and reports how many events it would have acted on, the decision breakdown, and samples of the matching calls. Zero matches on real traffic is the signal that enforcing won't disrupt your team.
Matching: tools vs LLM calls
Every request is either a tool call (MCP) or an LLM call. Use these expressions to target the right type:
| What to match | Expression |
|---|---|
| Any MCP tool call | object.mcp.tool != "" |
| A specific tool | object.mcp.tool == "bash" |
| Any LLM call | object.llm.model != "" |
| A specific model | object.llm.model == "claude-opus-4-8" |
Content safety policies
AIControls scans every LLM prompt and tool call inline before forwarding it. Content safety enforcement is done through policies — you choose whether to block, warn, or audit depending on your team's risk tolerance.
Two built-in policies carry the scanner's default disposition, enabled in every workspace:
content-safety-credentials(Enforce) — blocks any request whose scanned content contains a credential (private key, cloud/API token, connection string) or an unsafe-content finding.content-safety-pii(Warn Approve) — routes requests containing PII to the approval queue and holds them there until an admin decides.
Both read the scanner's findings via the object.request.* context fields — the same fields your own policies can use. Because the disposition is a policy, you control it like any other policy: switch content-safety-pii to Warn or Audit to loosen PII handling, tune its expression with a confidence threshold (e.g. !(object.request.hasPII && object.request.piiConfidence >= 0.5)), or grant a scoped exception. Disabling content-safety-credentials disables credential blocking on the request path entirely — findings are still scanned and visible to other policies and the audit log, but nothing blocks by default.
Content-safety findings govern the request; they are independent of what the audit log stores. Sensitive values detected in prompts and tool arguments are redacted from audit records regardless of whether a policy fired — see redaction of sensitive values. Both halves share one definition of each entity, so anything the audit log redacts is also something the scanner can see.
The scanner previously recognised phone numbers in North American formats only. It now also recognises numbers written with a leading + and a country code, in the groupings countries actually use — the same detection the audit log already applied to them.
An international number in a prompt or tool call was previously invisible here: it did not count toward object.request.hasPII, did not trip content-safety-pii, and never reached Security → Approvals. It now does all three, so traffic carrying international numbers that used to pass may start warning or being held for approval.
PII detection is threshold-gated — several distinct phone numbers must appear in one prompt or tool-call payload before a finding is raised — so a single number in a message does not fire on its own, and the thresholds themselves are unchanged. If the new detections are noisier than you want, switch content-safety-pii to Audit and measure against your real workload before graduating it back.
The Warn enforcement mode is particularly useful for content safety: 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. This gives you a soft interrupt without hard-blocking legitimate workflows.
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.
For worked content-safety examples (PII warnings, prompt injection blocking, credential-file access warnings, secret exfiltration blocking), see Write a Policy.
Built-in policy library
AIControls ships with a curated library of policies covering common governance scenarios, grouped into five categories — security, cost, compliance, operations, and autonomy governance. Security policies cover things like blocking credential patterns in prompts and denying requests with PII in tool arguments. Cost policies enforce model tiers by developer group and can block premium models for non-engineering roles. Compliance policies require audit mode for regulated data namespaces and can block data export tools. Operations policies rate-limit bash calls per session and require HITL approval for infrastructure mutations. Autonomy governance policies scale enforcement to each agent's autonomy tier — from logging every action at T1 to requiring approval for privileged operations at T4.
Enable library policies from Policies → Library — browse as cards or a sortable table. For the full enumerated list of built-in policies, see Policy Context.
Clone and customize a built-in
Built-in policies are refreshed on every upgrade, so they aren't directly editable. To change one, open it and click Clone & Customize. That does three things:
- Creates an editable copy under a new name. The copy is named after the original with a
-customsuffix —pii-scan-promptsbecomespii-scan-prompts-custom, and a second copy becomespii-scan-prompts-custom-2. The name is unique across your workspace, and the copy's YAML carries that same name in itsmetadata.name. The original's comments come across intact, so you can see why each match condition and validation is written the way it is. - Disables the original, so the two don't both act on the same traffic.
- Leaves the copy disabled until you have reviewed the YAML. Edit it, then enable it — use Replay first if you changed an expression.
The clone and the original are two entirely independent policies. Everything that identifies a policy uses its own name:
| Behaviour | |
|---|---|
| Enforcement | Each is enabled, disabled and mode-switched on its own. Disabling one has no effect on the other. |
| Audit log | Records the name of the policy that actually fired, so you can tell the clone's decisions from the original's. |
| Cooldowns | Tracked per policy name — a prompt suppressed by the original doesn't suppress the clone. |
| Exceptions | An exception must name the policy it should lift. To exempt someone from a clone, grant the exception against the clone's name. |
Earlier versions gave a clone the same internal identity as the policy it was cloned from, so an exception naming the original (pii-scan-prompts) also silenced its clone (pii-scan-prompts-custom). Clones now have their own identity, so that no longer happens. If you have an exception that relied on it, add the clone's name to the exception.
Default policy set
New workspaces start with an audit-first baseline: an audit-everything policy plus detections for secret reads, shell injection, privilege escalation, malicious file writes, sensitive file access, and command-and-control traffic — all in Audit mode, alongside non-blocking context-efficiency warnings. The one enforcing default is PII scanning in prompts. The onboarding wizard lets you review and customize this set; use Replay to build confidence before graduating any policy to Enforce.
Human-in-the-loop
Set proxy.nirmata.io/enforcement-mode: Approve (warn-approve) on any policy to route matching requests to the approval queue instead of blocking them outright. The developer's session is held; they see a pending state in their AI tool. Admins approve or deny in Security → Approvals — the session resumes immediately on approval or receives an error on denial.
Approval requests expire after a timeout, and the window differs by call type because the two have very different review economics:
| Held call | Default window | Why |
|---|---|---|
| MCP tool call | 30 minutes | The call can sit in the queue without the developer's tool timing out. |
| LLM request | 5 minutes | The hold is inline with no keepalives, so a long window is not a longer review opportunity — the AI tool gives up first and the entry expires unreviewed. |
If no admin acts within the window, the request is denied by default — configurable to instead let the call through and audit it. Both windows and the timeout disposition are workspace-wide settings, not something an individual policy sets: there is no per-policy approval-timeout annotation. Adjust them in Settings → General → Human Review; a self-hosted operator can also set the starting values in the deployment's Helm values (hitl.defaultTimeoutMinutes, hitl.llmDefaultTimeoutMinutes, hitl.timeoutAction), but a value saved in Settings takes precedence over Helm/config.yaml from then on.
content-safety-pii is an Approve policy and is enabled in every workspace. Previously it failed fast on LLM requests: a request containing PII was rejected immediately with the policy's message. It now parks the request for human approval instead, for up to 5 minutes, and the developer's AI tool waits.
Nothing needs to be reconfigured for this to work, but it does change what a developer experiences when PII is detected, and it means routine PII detections now land in Security → Approvals awaiting a decision. If you would rather keep the old fail-fast behaviour, switch content-safety-pii to Deny; to stop it blocking at all, switch it to Warn or Audit.
What the developer sees
Both MCP tool calls and LLM requests are held inline while approval is pending — chat completions, Anthropic messages, the OpenAI Responses API, Cursor, and Copilot all park the request rather than rejecting it. The developer's tool simply waits.
| Outcome | What the developer's tool receives |
|---|---|
| Admin approves | The request resumes and completes normally, as if the policy had allowed it |
| Admin denies | The request stops, with the reviewer's rejection reason surfaced |
| Timeout expires | The request stops, noting that no reviewer decided within the window |
How a denial or timeout is delivered depends on the endpoint. On the Anthropic messages endpoint — the one Claude Code uses — it comes back as a normal assistant message beginning Blocked by AIControls: rather than an HTTP error, so the reason appears in the conversation instead of an API-error banner. OpenAI-compatible endpoints (chat completions, the Responses API, Cursor, Copilot) return an HTTP error response instead, which the calling tool surfaces however it normally reports a rejected API call. On MCP tool calls the tool result is an error.
Token counting is the one exception. An Approve policy matching a token-count request blocks it immediately instead of holding it. Token counting never invokes the model, and AI tools call it many times per turn — holding it would fill the Approvals queue with entries nobody needs to review. The model request that follows for the same content gets the real hold.
A granted exception, or switching the policy's mode, lifts the approval requirement entirely. While the workspace is in observe-only mode an Approve policy never holds anything: the request continues and the decision is recorded as a shadow event in the Audit Log instead.
Approval requests are visible to every admin regardless of which proxy replica is holding the request, and survive a restart.
Consolidated approvals
One turn of an AI coding tool is rarely one model request. The tool typically makes several, and the content that tripped the policy stays in the conversation history, so the policy holds every one of them. Reviewed one by one, a single developer prompt could put five separate entries in Security → Approvals, each needing its own click while the developer waited through all of them.
AIControls consolidates these instead. While an approval is still pending, an identical call attaches to it rather than creating a second entry:
| Situation | Result |
|---|---|
| Identical call — same agent, same session, same policy, same content — arrives while the approval is pending | Attaches to the existing entry. The queue still shows one row, and one notification was sent. |
| You approve | Every attached call is released and proceeds. |
| You deny | Every attached call is stopped, and each developer sees your reason. |
| The window expires with no decision | Every attached call is stopped, exactly as an un-consolidated hold would be. |
| Anything differs — a different prompt, tool, model, agent, session, or policy | A separate entry requiring its own decision. |
| An identical call arrives after you decided, or after the window expired | A new entry requiring a new decision. |
That last row is the important one: a decision is consumed when you make it. Consolidation only ever groups calls a reviewer is looking at right now — it is not a cooldown, and no request is ever released on the strength of an approval you already spent. This holds across replicas too, so the consolidation still applies when the calls of one turn are served by different proxy pods.
Consolidation is automatic and has no setting. Each consolidated call is still recorded individually in the Audit Log when it resolves — only the review entry is shared, never the audit trail.
Configure who receives approval notifications in Settings → Notifications.