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.
A small set of built-in policies (and anything cloned from one) use an LLM-backed function whose fallback, with no delegate configured, would silently produce the opposite of the protection the policy's name promises — a blanket deny, or a no-op that looks like enforcement. Enabling one of these from the Policies page returns an error until llmExt.endpoint is configured; they're also force-disabled again on restart if the endpoint is later removed.
Replay — preview before enforcing
Before flipping a policy from Audit to Enforce, click Replay, select a time range, and dry-run it against your traffic. Replay reads the shared audit history, evaluates events newest first without affecting anything live, and reports coverage, matches, the decision breakdown, evaluation errors, unsupported records, and samples of matching calls. You can select up to 30 days at a time. A run processes at most 10,000 events or 10 seconds; when it reaches either bound, Replay marks the result as partial and lets you continue from a stable cursor instead of presenting an incomplete result as full coverage.
Replay evaluates the request phase for MCP and LLM audit events. It identifies records it cannot reconstruct and response phases it does not replay, so those records remain visible in the accounting rather than silently disappearing. The coverage panel also lists policy context that audit history does not retain, including identity labels, team membership, roles, token claims, client hints, and MCP method and parameter envelopes. Stored tool arguments can also contain credential or PII redaction applied before persistence. The bare now policy variable uses each event's timestamp; time.now() still uses replay execution time and appears as a limitation. Historical replay requires the shared audit store and never substitutes one proxy replica's in-memory event buffer. Zero matches is meaningful only when event coverage is complete and the listed context limitations do not affect the policy.
Replay and the Test Policy (Simulate) panel both flag when a policy uses an LLM-backed function: neither ever calls a real delegate, so llm.check/llm.classify/llm.score always fall back to their configured value, and an llm.assess* call instead reports status: "unknown" (or "failed" for invalid input) — none of it is a live model response.
Policy revisions and rollback
AIControls keeps an immutable, full YAML snapshot whenever you create or change a database-managed policy. Each policy has its own monotonic revision number. Repeating a save without changing the YAML does not create a duplicate revision, and changing only metadata or enforcement mode does not create a spec revision.
You can use the portal API to inspect history:
| Request | Result |
|---|---|
GET /api/v1/policies/{id}/revisions | Lists revisions newest first. Use limit and the returned nextCursor for pagination. |
GET /api/v1/policies/{id}/revisions/{revision} | Returns the complete YAML snapshot and its actor, source, hash, and timestamps. |
GET /api/v1/policies/{id}/revisions/{revision}/diff?against=current | Returns the structural changes between that revision and the current one. |
POST /api/v1/policies/{id}/revisions/{revision}/rollback | Restores the snapshot. Send { "expectedRevision": <current-revision> } in the request body. This admin-only action creates a new revision; it never rewrites history, and returns 409 Conflict if another writer changes the policy first. |
Deleting a user-created policy does not delete its revision history. AIControls keeps the last full snapshot for audit and comparison, but does not reconstruct a deleted policy from YAML alone because its non-spec metadata is no longer available. File- and Kubernetes-sourced policies are excluded: keep their history in the Git or GitOps system that owns them.
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.
Three 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.content-safety-injection(Warn Approve) — routes requests where the scanner detects a prompt-injection or jailbreak attempt (object.request.hasInjection) to the approval queue. Detection covers direct instruction-override phrasing (including paraphrases such as "disregard the rules above"), fake system-prompt delimiters, embedded executable markup, encoded payloads (base64, hex escapes), Unicode obfuscation (bidi overrides, zero-width characters), and runbook-style indirect injection: lines that address an AI assistant or agent directly (AI assistant:,LLM agent:), and hidden HTML comments that carry shell commands (cat,curl,wget), secret-shaped variable references ($GITHUB_TOKEN,$API_KEY), or a numbered step list phrased as a required procedure. JSON-escaped text (tool output, serialized bodies) is decoded before these checks run. Every finding, whatever its confidence tier, setshasInjectionand routes to approval under this policy; the lower tiers carry a lowerinjectionScore(0.7 for the indirect-injection patterns, 0.4 for the weakest), so a policy that needs to separate them can threshold on that score. AnAI assistant:line in visible text counts only when the same line also carries an imperative such as "must", "required" or "do not", so ordinary chat transcripts are not held. Ordinary HTML comments such as<!-- Describe your change -->are not findings. If the optional ML classifier sidecar (content safety) is enabled, this policy does not route on a classifier-only finding by itself — only when a finding from the pattern-based detectors above is also present. A raw, uncalibrated classifier score never starts holding requests the moment the sidecar is turned on; enable a dedicated policy on the classifier's confidence score once you've calibrated it against your own traffic.
All three 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.
Reporting and disabling false positives
A developer who believes a finding is wrong can flag it from wherever it surfaces — the Report false positive action on a Security Events finding, or on an Approvals exception/HITL request that a content-safety policy denied or held. This files a report into an admin review queue at Settings → Content Safety → Reports, alongside the finding it was raised against.
Reviewing a report there gives an admin two options: dismiss it, or Confirm & disable detector — which turns off that specific detector (entity type) fleet-wide, across every request and every replica, without disabling the whole content-safety-pii or content-safety-injection policy. Disabled detectors are listed at Settings → Content Safety → Detectors, where they can also be toggled directly (without going through a report) by an admin who already knows which entity type is too noisy for their workload.
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.
Tuning the ML injection classifier
content-safety-injection's pattern-based checks catch known instruction-override phrasing and obfuscation tricks. Workspaces with the optional ML classifier enabled add a second, model-based signal that scores prompts and tool arguments for injection and jailbreak intent, surfacing as two additional entity types alongside the pattern-based ones.
Settings → Content Safety → Prompt Safety shows whether the classifier is reachable, how many scans it's run and how many findings it's raised so far today (the current UTC calendar day — not a rolling 24 hours, so expect lower counts in the hours right after UTC midnight), and a score histogram — the shape you want to see is most scores bunched near zero, with only a small tail near the warn/deny thresholds used in your policy. A sudden shift in that shape (a wider tail, more scans landing near the threshold) is the signal to review recent traffic before tightening enforcement. From the same tab you can disable either entity type fleet-wide (same mechanism as disabling a detector) and adjust the classifier's request timeout, max windows per scan, and fail-open behavior — the classifier fails open by default, so a timeout or outage never blocks the underlying request; it only means those calls go unscored during the outage. Max windows per scan bounds how many sliding text windows the classifier scores per request (2-64): raising it improves detection coverage on long prompts and tool responses at the cost of more inference work per scan, lowering it trades some of that coverage for latency and throughput. Clearing either the timeout or max-windows field resets it to the config.yaml/Helm-configured default rather than leaving a stale override in place.
For worked content-safety examples (PII warnings, prompt injection blocking, credential-file access warnings, secret exfiltration blocking), see Write a Policy.
Bringing your own guardrail
External Guardrails let you point content-safety scanning at your own HTTP classifier — a fine-tuned model, an internal moderation service, anything that accepts text and returns a category — alongside the built-in Presidio and LlamaGuard detectors. Your endpoint's response label maps onto AIControls' own finding types (CREDENTIAL, PII, PROMPT_INJECTION, UNSAFE_CONTENT); an unmapped label defaults to a generic unsafe-content finding. Every scanned prompt, response, and tool call is sent to the endpoint you configure, so only point this at a classifier you trust with that data — the connect-time dial guard rejects a private-address endpoint on every live scan even if it briefly resolved elsewhere at save time.
For the step-by-step setup — endpoint, auth, response-field mapping, and the request/response contract your classifier must implement — see Configure an External Guardrail.
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
Every new workspace enables a default audit pack with four stable built-in policy names. These policies only record matches; they don't block, warn, or hold a request.
| Governance area | Built-in policy | Why it starts enabled | Promote after you verify |
|---|---|---|---|
| Data access | no-secret-reads | Secret and credential reads are high-signal events across development environments. | Review legitimate credential bootstrap flows in Replay, add narrow exceptions where required, then switch to Enforce. |
| Code execution | block-shell-injection | Shell metacharacters can indicate command injection, but some workflows use them intentionally. | Review intentional shell composition, clone and narrow the patterns if needed, then switch to Enforce. |
| Privilege escalation | no-privilege-escalation | Elevation commands such as sudo, su root, and unsafe permission changes deserve review everywhere. | Confirm that the policy covers your MCP command-tool names, review approved administrative workflows, then switch to Enforce. |
| Network egress | audit-unapproved-egress-destinations | Unknown outbound hosts are useful baseline evidence, but an untuned allowlist is environment-specific. | Inventory real destinations in Replay, customize the approved-host list in a clone, then switch the clone to Enforce. |
Each policy has a stable artifact ID formed as builtin-<policy-name> — for example, builtin-no-secret-reads — for revision history and API references. The visible policy name identifies matches in the audit log and Replay. Each policy begins at revision 1 from the shipped seed. Later shipped fixes create a new immutable revision without changing an operator-selected mode or enabled state.
This pack is a fresh-workspace default. An upgrade doesn't re-enable a policy you disabled or disable one you enabled. New workspaces also receive the broader starter set, including audit-everything, malicious-file-write, sensitive-file, command-and-control, autonomy, and content-safety policies. Some content-safety defaults enforce or hold requests to preserve their existing safety behavior; they aren't members of this audit-only pack.
The onboarding wizard links to the starter set from its Set up more list. Use Replay to build confidence before graduating an audit 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; on a streaming request their AI tool shows a notice naming the policy, the approval reference and the timeout (see When a call is held for approval). 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 developer is waiting in their AI tool for the whole hold, so a long window is not a longer review opportunity — the developer or the 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 holding pod's call through and audit it. Consolidated calls share the first call's approval window. A later caller is denied if that shared window ends before its own timeout, even when the timeout setting says to allow and audit. A call attached through another proxy pod is also denied at timeout if no reviewer decided: that pod cannot distinguish ordinary expiry from a withdrawal whose store update has not completed. 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 — the same card carries the default Standing exception duration for the exceptions an approval grants (see Every grant is time-boxed); a self-hosted operator can also set the starting values in the deployment's Helm values (hitl.defaultTimeoutMinutes, hitl.llmDefaultTimeoutMinutes, hitl.timeoutAction, hitl.grantDefaultMinutes), 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.
Gateway and ingress timeouts
A held request keeps its HTTP connection open for the whole approval window. Every hop in front of AIControls (an Envoy or Gateway API route, an Ingress controller, a cloud load balancer) must let a request stay open longer than that window, or it cuts the connection while the approval is still pending. The developer's tool then reports a dropped stream and often retries, and the entry is withdrawn from the queue because its caller disconnected. For example, a 180-second route timeout in front of the 5-minute LLM window cuts off every held LLM request before a reviewer can act. Set each route timeout above the longest window you use, with some margin, or shorten the window in Settings → General → Human Review. The Classic Ingress example in Step 6 of Self-hosted Kubernetes: getting started sets nginx's read and send timeouts to one hour. For a Gateway API route, set timeouts.request on the HTTPRoute rule (spec.rules[].timeouts.request), or your gateway implementation's equivalent traffic policy. When the Helm chart creates the HTTPRoute (gateway.enabled: true), set gateway.requestTimeout and the chart applies it to every rule. HTTPRoute timeouts need Gateway API CRDs v1.2.0 or later:
gateway:
enabled: true
requestTimeout: "1h" # Gateway API duration; empty (the default) leaves timeouts.request unset
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 |
For this outcome specifically, delivery does not depend on the endpoint: on every LLM-request wire format — the Anthropic messages endpoint Claude Code uses, the Chat Completions endpoint Cursor and Copilot use, and the OpenAI Responses API Codex uses — a denial or timeout returns a normal 200 assistant turn beginning Blocked by AIControls: rather than an HTTP error, so the reason appears in the conversation the same way for every client; only the JSON shape differs per endpoint (an Anthropic message, an OpenAI chat-completion object, or Responses output text). On MCP tool calls the tool result is an error instead, since there is no conversation turn to carry it. Ordinary policy denials and budget denials are endpoint-specific: /v1/messages generation requests use the same 200 synthetic assistant turn (while count_tokens and per-minute limits retain Anthropic error envelopes), Chat Completions uses an HTTP error unless a model-cap exception offer is available, and the Responses API uses a synthetic turn for offers/holds or a terminal response.failed event (HTTP 200 for streaming and HTTP 400 for non-streaming) for hard denials. See What users see when a budget is reached.
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. On an MCP tool call, the event's message reads "Observe mode: allowed and recorded. Without observe mode this call would have been held for approval." (for a Deny policy, "…would have been blocked."), and the shadow decision keeps the policy's own message. Observe-only mode never downgrades a workload access denial: containment, a fail-closed registration, or a stale access generation still denies the request.
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 tool, model, agent, session, or policy | A separate entry requiring its own decision. |
| The last caller on the holding proxy pod disconnects before you decide | The entry is withdrawn and leaves the queue, even if a caller on another pod is waiting. That remote caller is stopped, not released. |
| An identical call arrives after you decided, after the window expired, or after the entry was withdrawn because the last local caller disconnected | A new entry requiring a new decision. |
If the shared store cannot record a withdrawal, the holding pod retries it and rejects new local attachments to that entry. Until the write succeeds, another pod may still show the entry as pending. A reviewer can decide it and may create a linked standing exception. A disconnected call cannot resume; a live call attached through another pod can proceed only after an explicit approval. Reviewers should refresh the queue after a storage interruption before deciding an older hold.
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.
Whether a different prompt also consolidates depends on the policy. Most Approve policies treat a different prompt as a different request and give it its own entry — appropriate when the content itself is what's being reviewed. A small set of built-in policies opt out of that: content-safety-injection, content-safety-pii, llm-sensitive-topic-approval, and llm-model-tier-enforcement consolidate on agent, session, and policy alone, because for these the same underlying concern (the same injection pattern, the same sensitive topic, the same premium model) legitimately keeps firing across many turns of one task even though the accumulated conversation text differs slightly each time. Without this, a single flagged task could otherwise fill the queue with a near-identical entry every turn.
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.