Compiling a Behavior Declaration
A behavior declaration is never applied as written. AIControls compiles it into policy on two planes, under guardrails that can only narrow it, and records everything the compilation could not carry across. This page is the reference for what compiles where, what a compile can refuse to do, and the two places a declaration is deliberately not fully enforceable.
What you can observe today. Every push to POST /api/v1/bom/ingest returns a compile block on the response. It reports the status, the compiled allow lists, and every finding named on this page — breadth-cap and intern-cap rejections, unrenderable destinations, the dropped ports, the read/write collapse. aibom-scanner --output proxy prints the response, so a CI push is where you will see it.
The compile block is advisory. It never changes the status code: a declaration that fails to compile is still valid inventory, is still stored, and still counts. A push is rejected only by the validity checks in AI BOM Trust and Validity.
Three limits on the ingest-time compile, because a pushed document is not yet attached to anything running:
| At ingest | When the policy is applied | |
|---|---|---|
| Containers considered | one — the image the push describes | every container in the pod, unioned |
| Platform ceiling | none; the response says so in ceilingDetail | the namespace's ceiling, which narrows further |
| Policy object | none rendered — there is no workload to select yet | the RuntimePolicy objects on this page |
So an empty clamp list at ingest means no ceiling was applied, not nothing would be clamped. The response states which, and you should not read a clean preview as "this is the policy that will run".
What is not here yet is applying the compiled policy to a running workload. That needs the workload trust state, which arrives in the next release.
The practical consequence: this is the release to fix a declaration that would not compile. Everything a breadth cap or an intern cap rejects is a property of your BOM and fixable from CI today, long before any of it is enforced.
The compile table
| Declared | Compiles to | Plane |
|---|---|---|
llm.models, llm.providers | model-scope constraint | Gateway |
mcp.servers, mcp.tools | tool-access constraint | Gateway |
network.dns | egress allowlist | Gateway |
network.dns (same values) | RuntimePolicy.network allow, paired with deny: ["*"] | Kernel |
network.dns (negative form) | RuntimePolicy.dns allow — observe only; reports every other name the pod resolves | Kernel |
network.cidrs | RuntimePolicy.network allow, paired with deny: ["*"] | Kernel |
network.ports | part of the gateway allowlist | Gateway only — see below |
exec | RuntimePolicy.exec allow, paired with deny: ["*"] | Kernel (needs BPF-LSM) |
open.read, open.write | RuntimePolicy.open allow, paired with deny: ["*"] | Kernel (needs BPF-LSM) |
kubernetes | nothing — it is a posture declaration, checked against the ServiceAccount's real power | — |
One declared name reaches both planes. network.dns is not split between them. The kernel matches a domain from the pod's own DNS answers and a cluster Service name from Service and EndpointSlice informers, so the same name is expressible on both sides. The split is by what each plane can do with a name:
| Gateway | Kernel | |
|---|---|---|
| Granularity | per request — host, path, header, model, tool | per flow — destination |
| Sees the name from | the request itself | the pod's own DNS answer, or a Service informer |
| Needs the workload to cooperate | yes — it must honor the proxy settings | no |
| Enforces | which model, which tool, which prompt | whether a flow may leave at all |
| Blind to | a workload that routes around it | encrypted DNS, a cached answer, an address the pod never resolved |
Neither is a superset of the other, which is why a declared name compiles to both.
Two things a declaration cannot carry into the kernel
Both are recorded as compile findings rather than quietly dropped, because a silent omission is a policy that reads as fully enforced and is not.
No policy value has a port
network.ports reaches the gateway and stops there. There is no kernel policy value that carries a port, so a local model on 11434 or a vLLM server on 8000 is constrained by address or by Service name, or not at all.
You will hit this. A workload that talks to an in-cluster inference server on a non-standard port gets a kernel rule about the destination, and the port half of the declaration is enforced only for traffic that goes through the gateway. Compilation emits compile-ports-dropped naming the exact ports, so the gap appears in the compile findings rather than being discovered from the generated policy.
Ports are still worth declaring. They constrain the gateway plane, and they document intent for review.
The kernel open rule has no read/write mode
open.read and open.write are separate lists in your declaration. The kernel open rule takes one value list, so they are merged: a path you declared write-only compiles as readable too.
This is a genuine widening of the compiled policy relative to what you wrote, so it is reported as compile-open-mode-collapsed naming the affected paths. Keep the two lists separate anyway — they are accurate as a declaration, they are what conformance is checked against, and they are what a future kernel rule with a mode would use.
The four guardrails
1. Clamp, never widen
Every compiled value is intersected with the namespace's platform ceiling before it becomes policy. A declaration can describe a subset of what the platform already allows; it can never open something the platform closed.
A clamp is not a failure. The compile succeeds with the narrower set and records compile-clamped naming what was dropped and what was kept — so "my declared destination is not in the policy" has an answer rather than being a mystery.
2. AIControls renders the policy, not you
The declaration is expressed in the neutral behavior schema and AIControls renders the enforcement policy from it. There is no field through which a BOM can ship Custom Resource text. A document that could would let the team shipping a workload author the kernel enforcement policy applied to it, which is the wrong direction for a security control.
3. Breadth caps
A declaration matching more than a configured share of the filesystem or the address space is rejected at compile, with a message naming the offending patterns and the measured share. It is not narrowed, and it is not compiled — the alternative is a policy that permits everything while reporting as enforced.
| Cap | Default | Rejects | Accepts |
|---|---|---|---|
Filesystem, per open.read / open.write / exec list | 50% | /**, /* | /app/**, /etc/ssl/certs/**, /usr/local/bin/python3.11 |
IPv4 address space, across network.cidrs | 1/256 — one /8 | 10.0.0.0/4, 0.0.0.0/0 | 10.42.0.0/16, 10.0.0.0/8 |
The filesystem share is an estimate, and deliberately a coarse one: a pattern is scored by how many leading path segments it fixes before its first wildcard, so /** scores 100%, /app/** scores about 6%, and an exact path scores nothing. It is not measuring your filesystem — it is separating "permits a directory" from "permits everything". The address-space share is exact.
Declaring /** and expecting it to be silently reduced to something sensible is the case this refuses. Narrow the declaration to the directories the workload actually uses.
4. The 256-name cap on kernel domains
The sensor holds a bounded number of domain names per pod. A compiled allow list past that limit fails loudly rather than being truncated: a truncated allow list denies traffic you declared, which in enforce mode is an outage with no visible cause.
The cap is per pod, not per container, so a multi-container pod is where a realistic declaration trips it.
Cluster Service names cost nothing against this cap. A fully-qualified <service>.<namespace>.svc.cluster.local name resolves from Service and EndpointSlice informers rather than from DNS answers, so prefer it for every in-cluster destination — it is both more precise and free.
The pod is the unit, not the container
The kernel groups by pod. A pod with three containers has three declarations, and the compiled RuntimePolicy is their union — so a sidecar's declared permissions widen the application container's. A mesh proxy alone would blow open any meaningful exec or open allow list.
Three consequences:
- Known infrastructure sidecars are excluded from the union rather than merged, and the compiled policy is stamped with which ones —
pod-level (sidecars excluded: istio-proxy). - Union widening is reported. When merging genuinely grants something a container that constrained that list never declared,
compile-union-widenednames the container, the list, and the extra values. The effective policy being looser than the application asked for is something the operator should be told, not something to discover later. - A pod containing an undeclared container does not compile at all. Not partially — at all. Enforcing a declaration against a pod that also contains a container with no declaration proves nothing about the pod, so the pod stays untrusted. Excluded infrastructure sidecars do not count against this.
A single-container pod is never reported as widened. The warning fires only where a merge actually loosened something.
Monitor first, always
Every compiled kernel policy lands in monitor mode. Nothing about compiling a declaration arms enforcement.
Reaching enforce mode requires an explicit operator promotion that records who promoted it and when. There is no configuration flag, no trust level, and no score that arms a policy on its own — the system recommends and a human arms.
This is not caution for its own sake. A declaration drafted from too short an observation window, or one written for a workload with periodic behavior that did not occur during it, denies in the kernel and takes production with it. Monitor mode gives you the same findings with none of that risk: you see exactly what would have been denied, against real traffic, before anything is.
Two further limits on promotion:
- A BOM at trust level
noneorunverifiedcannot be promoted at all. A missing BOM is an adoption gap; a signature that does not verify is an incident. Neither is promotable, whoever asks. - The negative-form
dnsrule is always monitor, whatever the rest of the policy is doing. It only ever observes.