AI BOM Behavior Section
An AI BOM is a CycloneDX-compatible document describing the AI components found in one repository. Two sections make it more than inventory: subject, which binds the document to one immutable image, and behavior, which declares what that image will do. This page is the field-by-field reference for both, plus the validation rules a document must satisfy to be accepted.
This release accepts, validates, and stores the behavior section, and compiles it. Every push to the ingest endpoint returns a compile block naming what your declaration would become and everything that would stop it — see Compiling a Behavior Declaration for the guardrails, the findings, and the two places a declaration is deliberately not fully enforceable.
That block is advisory: a declaration that fails to compile is still valid inventory and the push still succeeds.
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 "plane" column below is where each primitive is enforced once it is.
An absent behavior section is valid
Most AI BOMs produced today have no behavior section at all, and that is a fully valid document. It still confers inventory — the components, their categories, their risk scores, the git provenance. It simply has nothing to enforce.
The same is true of subject. A document with neither section parses, stores, and reports exactly as it does today. Adding behavior is an opt-in step that turns an inventory artifact into an enforceable declaration; nothing about it is required, and nothing already in the field breaks when it is introduced.
The two discriminators
Every component in an AI BOM carries two type-like fields, and only one of them is the AI BOM's own.
| Field | Values | What it is |
|---|---|---|
type | ml-model, library | The CycloneDX component type. Only ever these two values. It says nothing about what the component does. |
category | agent, task, orchestrator, tool, model, memory, embedding, guardrail, retriever, mcp-server, mcp-tool | The AI BOM discriminator. This is the field that identifies the component. |
Filter on category, never on type. A policy expression comparing type against "agent" — or any other category name — matches nothing, because type only ever holds ml-model or library. It does not error. The count reads zero, the match set is empty, and the policy passes every workload while looking like it works.
This is the single most expensive mistake available in this format, precisely because it fails silently in the permissive direction.
# correct — matches the agent, task, and orchestrator components
category in ["agent", "task", "orchestrator"]
# wrong — matches nothing, so it enforces nothing
type == "agent"
Three categories all describe something that acts like an agent, and any count or match that means "agents" should include all three: agent, task, and orchestrator. A crew or graph orchestrator and a standalone task are agents for every governance purpose. Matching agent alone under-counts.
The remaining categories are tool (a function an agent declares), model, memory, embedding, guardrail, retriever, mcp-server, and mcp-tool. An MCP tool is counted apart from a tool because it is a capability of a server, not a function of the agent.
The ingest response breaks out agentCount, toolCount, models, mcpServers, and mcpTools individually. memory, embedding, guardrail, and retriever are currently summed into a single other count.
subject — binding the document to an image
behavior describes exactly one immutable image. subject is what says which one.
| Field | Type | Required | Meaning |
|---|---|---|---|
digest | string | For enforcement | The image digest, sha256: followed by 64 lowercase hex characters. The field the running workload is checked against. |
image | string | Recommended | The reference the digest was resolved from. Its tag is advisory and is never used for binding — a tag is mutable. Its repository is what identifies which running containers the declaration describes, so digest binding reports unknown without it. |
repo | string | no | Source repository URL. Advisory. |
commit | string | no | Source commit. Advisory. |
"subject": {
"digest": "sha256:3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea",
"image": "ghcr.io/acme/rag-indexer:1.4.2",
"repo": "https://github.com/acme/rag-indexer",
"commit": "a3f8c12"
}
Two consequences worth being plain about:
- Without a digest, the document is inventory-only. There is nothing to bind a declaration to, so nothing can be enforced from it.
- A digest that does not match what is actually running means the document does not describe the running workload. The declaration belongs to some other build. It is not a weaker version of the same statement; it is a statement about a different artifact.
Only sha256 digests are accepted. Another algorithm is rejected rather than stored, because a digest that cannot be compared is worse than no digest.
behavior — the enforceable declaration
A complete example, showing every primitive:
{
"bomFormat": "AIBOM",
"specVersion": "1.0",
"generatedAt": "2026-08-01T09:15:00Z",
"subject": {
"digest": "sha256:3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea",
"image": "ghcr.io/acme/rag-indexer:1.4.2",
"repo": "https://github.com/acme/rag-indexer",
"commit": "a3f8c12"
},
"behavior": {
"schemaVersion": 1,
"llm": {
"models": ["claude-sonnet-4-6"],
"providers": ["anthropic"]
},
"mcp": {
"servers": ["filesystem"],
"tools": ["read_file", "list_directory"]
},
"network": {
"dns": ["api.anthropic.com", "qdrant.data.svc.cluster.local"],
"cidrs": ["10.42.0.0/16"],
"ports": [443, 6333]
},
"exec": ["/usr/local/bin/python3.11"],
"open": {
"read": ["/app/**", "/etc/ssl/certs/**", "/data/**"],
"write": ["/tmp/**"]
},
"kubernetes": {
"apiAccess": "none"
}
}
}
Every primitive is optional. An empty "behavior": {} is legal and meaningful — it declares that the workload does none of these things. "behavior": null reads as absent, not as an empty declaration.
Primitives and the plane each one is designed for
Two enforcement planes exist. The gateway (L7) plane sees requests — model names, MCP tool names, request authorities. The kernel plane sees syscalls and packets — it cannot read a model name, and it has no concept of a port in a policy value.
The "plane" column below is what each primitive compiles to. See Compiling a Behavior Declaration for the full compile table, including the negative-form dns rule and the guardrails every value passes through — and the status note at the top of this page for what is applied today.
| Field | Type | Plane | Notes |
|---|---|---|---|
llm.models | string[] | Gateway (L7) only | Model identifiers the workload will call. The kernel cannot read a model name. |
llm.providers | string[] | Gateway (L7) only | Provider names the workload will call. |
mcp.servers | string[] | Gateway (L7) only | MCP servers the workload will use. |
mcp.tools | string[] | Gateway (L7) only | MCP tools the workload will call. |
network.dns | string[] | Gateway and kernel | Bare host names. The kernel side matches a domain from the pod's own DNS answers, and a cluster Service name from Service and EndpointSlice data. |
network.cidrs | string[] | Gateway and kernel | a.b.c.d/n (IPv6 prefixes are also accepted). |
network.ports | int[] | Gateway (L7) only — excluded from the kernel policy by design | No kernel policy value carries a port, so declared ports are explicitly excluded from the kernel rendering rather than silently ignored. A port declared here is intended to constrain the gateway plane and nothing below it. |
exec | string[] | Kernel only | Absolute paths to executables the workload will run. Requires BPF-LSM on the node. |
open.read | string[] | Kernel only | Absolute path patterns the workload will read. Requires BPF-LSM. |
open.write | string[] | Kernel only | Absolute path patterns the workload will write. Requires BPF-LSM. |
kubernetes.apiAccess | string | Declaration, not enforcement | One of none, read, declared. |
kubernetes.rules | rule[] | Declaration, not enforcement | Only meaningful with apiAccess: "declared". Each rule takes apiGroups, resources, verbs. |
network.ports is the one asymmetric primitive: it is a gateway-plane declaration and stops there. Declaring "ports": [6333] will not restrict which ports the workload may open at the kernel level, and the kernel policy AIControls renders will not mention it. Read the table row, not a footnote — this is a real gap in coverage, stated rather than hidden.
kubernetes — a declaration, never a grant
"kubernetes": {
"apiAccess": "declared",
"rules": [
{ "apiGroups": [""], "resources": ["pods"], "verbs": ["get", "list"] }
]
}
apiAccess | Meaning |
|---|---|
none | The workload does not talk to the API server. |
read | Read-only API use. |
declared | The specific verbs and resources are enumerated in rules. |
rules may only appear with apiAccess: "declared". Each rule requires both verbs and resources; apiGroups may be omitted (use [""] for the core group).
This section grants nothing. It is checked against what the workload's ServiceAccount can actually do. A workload declaring apiAccess: "none" while bound to cluster-admin produces a finding, not a permission.
Strictness and versioning
Parsing is deliberately asymmetric:
- The CycloneDX base —
metadata,components,relationships, and anything a future scanner release adds — is read leniently. Unknown fields there are preserved, not rejected, so fields AIControls does not read still survive into storage. subjectis also read leniently. Unknown keys besidedigestare ignored, and asubjectin a shape AIControls cannot read — an in-toto/SLSA statement'ssubjectarray, for instance — is treated as no subject, which means inventory-only. Only adigestthat is present and malformed is an error.behavioris read strictly. An unrecognised key anywhere under it, including nested inside a primitive, is a hardbom-invaliderror.
The asymmetry is the point, and it is specifically about under-enforcement. An unrecognised metadata field costs nothing, and an unknown key next to subject.digest cannot change what we do. An unrecognised behavior primitive is different: it means the producer declared something AIControls will not compile, and the alternative to rejecting it is to enforce the rest while reporting the workload as fully enforced. Under-enforcing silently is exactly the failure this section exists to prevent.
The document is stored field-for-field, not byte-for-byte. Top-level keys come back alphabetically ordered, insignificant whitespace is normalised, and components below the configured minimum-confidence threshold are dropped. Treat the stored copy as semantically your document — if you need to verify a signature, verify it against the artifact in your own registry.
"behavior": {
"schemaVersion": 1,
"llm": { "models": ["claude-sonnet-4-6"] },
"gpu": { "devices": ["/dev/nvidia0"] }
}
That document is rejected. The error names gpu and says why: this build cannot compile it, and enforcing the rest while ignoring it would report the workload as fully enforced when it is not. The same applies to an unknown key nested inside a primitive — network.protocols is rejected just as gpu is.
behavior.schemaVersion
schemaVersion is an integer. Omitting it means version 1. It exists so that a document written against a newer schema is rejected with a diagnostic naming the version, rather than an anonymous complaint about whichever new key happened to sort first:
bom-invalid: behavior.schemaVersion: document declares behavior schema v2;
this build understands v1 — upgrade AIControls to enforce this declaration
The two consequences for you are asymmetric, on purpose:
- Adding a primitive never invalidates a document already in the field. Existing documents do not contain the new key, so they keep parsing unchanged. No migration, no re-scan, no coordinated upgrade.
- A document using a primitive newer than your AIControls build is rejected, loudly, naming the version. Fix: upgrade AIControls, or emit against the older schema. There is no partial-enforcement middle ground, because "enforce a subset while claiming to enforce all of it" is the outcome the design refuses.
What a behavior section cannot do
An AI BOM is authored by the team that ships the workload. Its authority is bounded accordingly.
- It cannot widen anything. Every rule compiled from a declaration will be intersected with the namespace's platform policy. A declaration can only ever describe a subset of what the platform already allows; it can never open something the platform closed.
- It cannot grant Kubernetes RBAC.
kubernetesis a statement of expected use, checked against the ServiceAccount's real power. Declaring access does not confer it, and declaringnonewhile holding broad permissions is a finding. - It cannot raise its own trust level. There is deliberately no field through which a document could assert how much it should be believed. See AI BOM Trust and Validity.
- It cannot self-attest freshness.
generatedAtis an assertion by the producer, not a fact established by it. - It never carries raw Custom Resource YAML. The declaration is expressed in this neutral schema, and AIControls — not the document — renders the enforcement policy from it. A document shipping CR text would let a developer author kernel enforcement policy for their own workload. There is no field through which this could be attempted.
Validation rules
The rules below are the schema check — one of three always-on checks. The other two, digest binding and freshness, are covered in AI BOM Trust and Validity along with every failure code and whether it blocks.
Every failure below is reported as bom-invalid with the dotted field path — for example behavior.network.cidrs[0] — so the message names the entry that failed, not just the document.
| Rule | Rejected | Accepted |
|---|---|---|
subject.digest must be sha256: + 64 hex characters | sha256:short, ghcr.io/acme/app:1.0 | sha256:3f79bb…46dea |
network.dns entries are bare host names — no scheme | https://api.anthropic.com | api.anthropic.com |
| …no path | api.anthropic.com/v1 | api.anthropic.com |
| …and no port | qdrant.data:6333 | qdrant.data.svc.cluster.local plus "ports": [6333] |
network.cidrs must parse as a CIDR | 10.42.0.0 | 10.42.0.0/16, 2001:db8::/32 |
| …with host bits clear | 10.42.0.5/16 | 10.42.0.0/16 |
network.ports must be 1–65535 | 0, 70000 | 443 |
exec entries must be absolute paths | python3 | /usr/local/bin/python3.11 |
open.read / open.write entries must be absolute paths | app/** | /app/** |
…and must not contain a .. segment | /app/../etc/** | /etc/** |
kubernetes.apiAccess must be a known mode | admin | none, read, declared |
kubernetes.rules require apiAccess: "declared" | rules alongside apiAccess: "read" | rules alongside apiAccess: "declared" |
Each rule needs both verbs and resources | {"resources": ["pods"]} | {"resources": ["pods"], "verbs": ["get"]} |
A host:port DNS entry is rejected rather than trimmed because it would never match on either plane — the gateway compares request authorities and the kernel matches DNS answers, and neither sees a host with a port glued to it. Silently accepting it would produce a declaration that enforces nothing while reading as if it did.
A short cluster Service name such as qdrant.data is accepted, not rejected, and not expanded — it is a syntactically valid external domain, so neither rejecting nor guessing would be right. But it will be read as an external domain, which is almost certainly not what you meant. Write the fully-qualified <service>.<namespace>.svc.cluster.local form. The same reasoning applies to a .. path segment, which is rejected: there the resolved path is unambiguous, so silently rewriting it would be the only alternative, and a rule that reads as /app/... while matching /etc/... is worth failing over.
See also
- Compiling a Behavior Declaration — what each primitive compiles to, the four guardrails, and what is deliberately not enforceable
- AI BOM Trust and Validity — the validity checks a document must pass, the trust levels, and the
requireBOMTrustfloor - Runtime Guard — the kernel-plane observations that
network,exec, andopencorrespond to - Network Egress Filtering — governing outbound HTTP(S) at the gateway plane
- Policy Context — the fields available to a CEL policy expression