Skip to main content

AI BOM Behavior Section

Reference

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.

Current status — declarations compile and every push tells you the result; nothing is applied yet

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.

FieldValuesWhat it is
typeml-model, libraryThe CycloneDX component type. Only ever these two values. It says nothing about what the component does.
categoryagent, task, orchestrator, tool, model, memory, embedding, guardrail, retriever, mcp-server, mcp-toolThe AI BOM discriminator. This is the field that identifies the component.
warning

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.

FieldTypeRequiredMeaning
digeststringFor enforcementThe image digest, sha256: followed by 64 lowercase hex characters. The field the running workload is checked against.
imagestringRecommendedThe 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.
repostringnoSource repository URL. Advisory.
commitstringnoSource 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.

FieldTypePlaneNotes
llm.modelsstring[]Gateway (L7) onlyModel identifiers the workload will call. The kernel cannot read a model name.
llm.providersstring[]Gateway (L7) onlyProvider names the workload will call.
mcp.serversstring[]Gateway (L7) onlyMCP servers the workload will use.
mcp.toolsstring[]Gateway (L7) onlyMCP tools the workload will call.
network.dnsstring[]Gateway and kernelBare 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.cidrsstring[]Gateway and kernela.b.c.d/n (IPv6 prefixes are also accepted).
network.portsint[]Gateway (L7) only — excluded from the kernel policy by designNo 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.
execstring[]Kernel onlyAbsolute paths to executables the workload will run. Requires BPF-LSM on the node.
open.readstring[]Kernel onlyAbsolute path patterns the workload will read. Requires BPF-LSM.
open.writestring[]Kernel onlyAbsolute path patterns the workload will write. Requires BPF-LSM.
kubernetes.apiAccessstringDeclaration, not enforcementOne of none, read, declared.
kubernetes.rulesrule[]Declaration, not enforcementOnly meaningful with apiAccess: "declared". Each rule takes apiGroups, resources, verbs.
note

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"] }
]
}
apiAccessMeaning
noneThe workload does not talk to the API server.
readRead-only API use.
declaredThe 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 basemetadata, 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.
  • subject is also read leniently. Unknown keys beside digest are ignored, and a subject in a shape AIControls cannot read — an in-toto/SLSA statement's subject array, for instance — is treated as no subject, which means inventory-only. Only a digest that is present and malformed is an error.
  • behavior is read strictly. An unrecognised key anywhere under it, including nested inside a primitive, is a hard bom-invalid error.

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.

What is stored

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. kubernetes is a statement of expected use, checked against the ServiceAccount's real power. Declaring access does not confer it, and declaring none while 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. generatedAt is 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.

RuleRejectedAccepted
subject.digest must be sha256: + 64 hex characterssha256:short, ghcr.io/acme/app:1.0sha256:3f79bb…46dea
network.dns entries are bare host names — no schemehttps://api.anthropic.comapi.anthropic.com
…no pathapi.anthropic.com/v1api.anthropic.com
…and no portqdrant.data:6333qdrant.data.svc.cluster.local plus "ports": [6333]
network.cidrs must parse as a CIDR10.42.0.010.42.0.0/16, 2001:db8::/32
…with host bits clear10.42.0.5/1610.42.0.0/16
network.ports must be 1–655350, 70000443
exec entries must be absolute pathspython3/usr/local/bin/python3.11
open.read / open.write entries must be absolute pathsapp/**/app/**
…and must not contain a .. segment/app/../etc/**/etc/**
kubernetes.apiAccess must be a known modeadminnone, 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