Skip to main content

Compiling a Behavior Declaration

Reference

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.

Current status — you can see what your declaration compiles to; nothing is applied yet

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 ingestWhen the policy is applied
Containers consideredone — the image the push describesevery container in the pod, unioned
Platform ceilingnone; the response says so in ceilingDetailthe namespace's ceiling, which narrows further
Policy objectnone rendered — there is no workload to select yetthe 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

DeclaredCompiles toPlane
llm.models, llm.providersmodel-scope constraintGateway
mcp.servers, mcp.toolstool-access constraintGateway
network.dnsegress allowlistGateway
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 resolvesKernel
network.cidrsRuntimePolicy.network allow, paired with deny: ["*"]Kernel
network.portspart of the gateway allowlistGateway only — see below
execRuntimePolicy.exec allow, paired with deny: ["*"]Kernel (needs BPF-LSM)
open.read, open.writeRuntimePolicy.open allow, paired with deny: ["*"]Kernel (needs BPF-LSM)
kubernetesnothing — 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:

GatewayKernel
Granularityper request — host, path, header, model, toolper flow — destination
Sees the name fromthe request itselfthe pod's own DNS answer, or a Service informer
Needs the workload to cooperateyes — it must honor the proxy settingsno
Enforceswhich model, which tool, which promptwhether a flow may leave at all
Blind toa workload that routes around itencrypted 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.

CapDefaultRejectsAccepts
Filesystem, per open.read / open.write / exec list50%/**, /*/app/**, /etc/ssl/certs/**, /usr/local/bin/python3.11
IPv4 address space, across network.cidrs1/256 — one /810.0.0.0/4, 0.0.0.0/010.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-widened names 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 none or unverified cannot 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 dns rule is always monitor, whatever the rest of the policy is doing. It only ever observes.

Compile findings

Every outcome is reported as structured data with a stable code, so it can be displayed, filtered, and acted on rather than read out of a log.

CodeBlockingMeaning
compile-undeclared-containeryesA container in the pod has no declaration, so the pod is untrusted rather than partially trusted
compile-breadth-capyesA declaration matches more than the configured share of the filesystem or address space
compile-intern-capyesThe compiled domain allow list is past the sensor's per-pod limit
compile-promotion-refusedyesEnforce mode was requested for a BOM whose trust level confers no enforcement authority
compile-render-failedyesThe rendered policy failed validation before anything was applied
compile-clampednoDeclared values were dropped because the platform ceiling does not permit them
compile-union-widenednoThe merged pod policy grants something a container never declared
compile-ports-droppednoDeclared ports have no kernel rendering
compile-open-mode-collapsednoA write-only path compiled as readable
compile-sidecar-excludednoA container was excluded from the union as infrastructure

A blocking finding means no policy is produced at all — not a partial one. A partially compiled policy would be a policy nobody wrote.

A declaration with nothing kernel-renderable — gateway-only primitives, or an empty section — is not a failure. It compiles the gateway half and reports nothing-to-compile, which is the expected outcome for most AI BOMs produced today.

Two of these codes cannot appear on an ingest response, because they are about a pod rather than a document: compile-union-widened and compile-sidecar-excluded both need the pod's other containers, and a push describes one image. compile-undeclared-container likewise never fires at ingest — a document with no behavior section reports nothing-to-compile, which is a different fact from a pod whose containers disagree.

Reading the compile block

"compile": {
"status": "failed",
"mode": "monitor",
"ceiling": "none",
"ceilingDetail": "compiled against no platform ceiling, so the absence of a compile-clamped finding means none was applied — not that nothing would be clamped …",
"scope": "one container, no platform ceiling, no policy object rendered: this is a preview of what the declaration would compile to, not a policy that has been applied",
"diagnostics": [
{
"code": "compile-breadth-cap",
"severity": "error",
"path": "behavior.open.read",
"detail": "behavior.open.read matches an estimated 100% of the filesystem, over the 50% cap. Broadest first: /** (~100%) …",
"attempted": ["/**"]
}
],
"l7": { "egressDomains": ["api.anthropic.com"], "ports": [443] }
}

status is compiled, nothing-to-compile, or failed. severity is error (blocking), warning, or info; only error sets status to failed. attempted and kept carry the values, so a client can act on them without parsing detail.

Gate your CI on status == "failed" if you want a declaration that would not compile to fail the build. AIControls will not do it for you — the push succeeds either way, by design.

See also