Skip to content

ToolPolicy CRD

The ToolPolicy custom resource defines Cedar access-control policies for tool invocations. CEL producers compute the facts a policy reads. The policy-broker sidecar in the agent pod evaluates the policies: the runtime calls it once per server-executed tool call (POST /v1/decision) before the tool runs. See Policy Engine Architecture for the full PDP/PEP model.

apiVersion: omnia.altairalabs.ai/v1alpha1
kind: ToolPolicy

Short name: tp (e.g. kubectl get tp).

Defines which tools this policy applies to. The broker matches incoming decision requests based on the X-Omnia-Tool-Registry and X-Omnia-Tool-Name headers.

FieldTypeRequiredDescription
registrystringYesName of the ToolRegistry to match.
tools[]stringNoSpecific tool names to match. If empty, applies to all tools in the registry.
spec:
selector:
registry: customer-tools
tools:
- process_refund
- issue_credit

Controls how the policy is applied.

ValueDescription
enforce(Default) The broker returns allow: false for a matched deny rule; the runtime aborts the tool dispatch instead of calling the tool. A denial is not an HTTP error status. See Denial response format.
auditThe policy is evaluated but neither grants nor refuses: it only reports. When it would have denied, the decision returned to the runtime carries wouldDeny: true with deniedBy/message naming the statement, and the runtime records a would_deny policy decision on the session. Whether the call runs is decided by the matching enforce policies; when only audit-mode policies match, it runs. An audit policy with no permit statement reports only its matching forbids, never no-matching-permit, so a forbid-only guardrail can be trialled on its own.

Defines behavior when evaluation encounters an error: a producer that fails to compute its fact, or a Cedar statement that cannot be evaluated.

ValueDescription
deny(Default) Deny the request, with deniedBy set to producer-failed or evaluation-error.
allowTreat the policy as permitting the request despite the error.

A headerInjection CEL expression that errors does not change the decision. Under allow that one header is skipped; under deny the call proceeds with no injected headers at all, and the broker logs the error.

Optional. Each producer computes one named fact from the tool call, which the policy reads as context.<name>.

A policy cannot read the tool call’s arguments directly. Arguments reach it only through built-in context and producers.

FieldTypeDescription
namestringThe key the fact appears under in context. Must be unique, and must not shadow a built-in.
typestringcel: the broker evaluates the expression in-process.
celstringThe expression. Required when type: cel. Reads body (the tool call’s arguments), headers, tool (the pack’s declared action scope for this tool), identity and governance (the pack’s governance block). The result must be a bool, string or int, and its type must be known when the policy compiles: a bare field read such as body.amount is untyped, so convert it with int(...), string(...) or bool(...).
producers:
- name: containsRawStatement
type: cel
cel: >-
has(body.evidence) && body.evidence.exists(e,
e.type.matches("(?i)^bank[ _-]?statement"))

The broker supplies these for every request, so a policy works with no producers declared:

KeyTypeMeaning
context.argBytesLongJSON-encoded size in bytes of the tool call’s arguments. 0 when the call has no arguments.
context.argNamesSet<String>Top-level argument names present.
context.hasAttachmentsBooltrue when any top-level argument is a string beginning with omnia://. Nested values are not inspected.

The raw argument map is not exposed in context.

Required. Cedar policy text.

Cedar is default-deny: a call is permitted only by a matching permit, and any matching forbid overrides it. Precedence is explicit deny → allow → implicit deny.

policy: |
@id("permit-underwriting")
permit (principal, action == Action::"invoke",
resource in Registry::"mortgage-tools");
@id("deny-raw-statement-egress")
@message("Send the derived income summary only.")
forbid (principal, action == Action::"invoke", resource)
when { context.containsRawStatement };
AnnotationEffect
@id("name")Reported as deniedBy on a denial. Falls back to Cedar’s own policy id when absent.
@message("...")Returned to the caller as message, and reaches the model as the tool result.
CedarBuilt fromTrust
principalOmniaPrincipal::"<subject>", parents Workspace:: / Agent::, attributes origin, subject, endUser, workspace, agent, claimsverified by the auth chain
actionAction::"invoke"fixed
resourceTool::"<registry>::<tool>", parent Registry::"<registry>"resolved by the runtime
contextbuilt-ins plus one entry per producerderived from what the caller sent

The principal is the subject, not the end user. The subject is what the auth chain verified; the end user is often propagated and is an attribute. A propagated identity has an empty origin, so principal.origin != "" requires a directly-verified caller.

principal.claims holds the token’s claims keyed by lowercased claim name, so the condition is principal.claims has team, not has Team. A name that is not a Cedar identifier is quoted: principal.claims has "customer-id". spec.externalAuth.oidc.claimMapping maps only subject and endUser; other claims keep their own names.

The controller validates spec.policy against a Cedar schema it generates from the policy’s own declarations. You do not write the schema.

PartDeclared as
Entity typesOmniaPrincipal in [Workspace, Agent], Workspace, Agent, Registry, Tool in Registry
Actioninvoke, applying to principal OmniaPrincipal and resource Tool
principal attributesorigin, subject, endUser, workspace, agent: String. claims: a record with one optional String attribute per claim name the policy reads
contextargBytes: Long, argNames: Set<String>, hasAttachments: Bool, and one attribute per producer
Producer typesCEL bool → Bool, int → Long, string → String

Validation uses Cedar’s strict mode. The policy goes to phase Error when a statement:

  • reads or tests a context key no producer or built-in supplies, such as context.piiscore or context has piiscore beside a producer named piiScore;
  • compares a key with a value of another type, such as context.piiScore == "high" for an int producer;
  • names an entity type, action or attribute that is not in the table, including in an is test.

A statement’s when and unless clauses are validated together, as one condition, so a has test in one clause guards a read in a later one.

A claim is optional because a credential need not carry it. Test it with has before you read it: principal.claims has team && principal.claims.team == "x". Reading principal.claims.team without the test fails validation.

A producer the policy reads must yield a bool, int or string that is known when the policy compiles. A producer whose result is untyped (dyn) or of another type puts the policy in phase Error, with the message under spec.producers. A producer the policy does not read is not type-checked.

Strict mode also rejects two forms that are valid Cedar. Rewrite them:

RejectedRewrite
principal in [Workspace::"w", Agent::"a"] (a set mixing entity types)principal in Workspace::"w" || principal in Agent::"a"
!(principal.claims has team) || principal.claims.team == "x" (a guard that holds only through ! and ||)!(principal.claims has team) || (principal.claims has team && principal.claims.team == "x")

The broker runs the same validation. An edit that fails it is not applied: a broker that holds the previous version keeps enforcing that version, the same as for a parse error. A broker that holds no version of the policy, such as a broker that has restarted, loads the policy anyway and logs ToolPolicy failed schema validation.

Headers to inject into the upstream tool request when the call is allowed — HTTP headers for http/openapi handlers, gRPC metadata for grpc, and transport headers for MCP. A denied call gets no injected headers. Each rule provides either a static value or a dynamic cel expression (mutually exclusive; the controller rejects a rule with both or neither).

FieldTypeRequiredDescription
headerstringYesHTTP header name to inject.
valuestringConditionalStatic header value. Mutually exclusive with cel.
celstringConditionalCEL expression computing the header value. Reads headers, body, identity, tool and governance, the same variables a producer reads. Mutually exclusive with value.
spec:
headerInjection:
# Static value
- header: X-Policy-Version
value: "v1"
# Dynamic value from claims
- header: X-Tenant-Id
cel: 'headers["X-Omnia-Claim-Customer_id"]'
# Computed value
- header: X-Request-Source
cel: '"policy-broker/" + headers["X-Omnia-Agent-Name"]'
ValueDescription
ActiveThe Cedar policy, every producer and every header-injection expression compiled.
ErrorSomething failed to compile (a Cedar parse error, a statement that fails schema validation, an invalid CEL expression, a producer whose result type is not bool, int or string, a duplicate or built-in-shadowing producer name).

When an edit fails to compile or fails schema validation, a broker that already holds the previous version keeps enforcing it; the broken edit is reported here, not applied. A broker that holds no version of the policy loads a schema-invalid policy that parses.

Number of compiled Cedar statements — permits plus forbids. Producers are not counted: a producer computes a fact, it does not decide anything.

Zero when the policy text did not compile (see the Compiled condition), and when the text compiles with no statements.

Number of advisory conditions (HeaderRefsCanonical, and RuleSoundness where it is set) at False for the current generation. Advisories never block: a policy with an advisory firing stays Active and in force. Read the conditions for each reason and the message naming the offending expression and its fix.

Zero is not a proof that the policy is sound. The checks catch known mistakes and miss others.

sha256:<hex> over the authored spec. It changes when the policy text, producers, selector, mode, onFailure or header injection change, and matches the policyDigest recorded on each policy decision, so a recorded verdict can be traced to the exact version of the policy that produced it. Empty while the policy does not compile.

TypeMeaning
ReadyTrue (reason PolicyValidated) when the policy compiled; False (reason CompileError) otherwise.
CompiledTrue (reason PolicyCompiled, message N policy statements compiled successfully) or False (reason CompileError, message carrying the compiler error).
HeaderRefsCanonicalAdvisory. False (reason NonCanonicalHeaderRef) when a producer or header-injection expression reads headers["..."] with a non-canonical key such as x-omnia-tool-name; those keys never match, because decision-request headers arrive in canonical form. Never blocks the policy. Counted in advisoryCount.

The most recent .metadata.generation observed by the controller.

When using kubectl get toolpolicies, the following columns are displayed:

ColumnSource
Ready.status.conditions[?(@.type=='Ready')].status
Registry.spec.selector.registry
Mode.spec.mode
Phase.status.phase
Rules.status.ruleCount
Advisories.status.advisoryCount
Age.metadata.creationTimestamp

The broker answers every well-formed POST /v1/decision with HTTP 200. A body that does not decode as a decision request gets 400 with {"error":"malformed_request"}, and any method other than POST gets 405 with {"error":"method_not_allowed"}. A denied call is expressed in the decision body:

{
"allow": false,
"deniedBy": "max-refund-amount",
"policy": "refund-limits",
"policyDigest": "sha256:…",
"message": "Refund amount exceeds the $500 limit",
"mode": "enforce",
"wouldDeny": false,
"injectedHeaders": null
}

The runtime reads allow: false, aborts the tool dispatch, and returns message to the model as the tool result (tool call denied by policy: <message>) instead of invoking the tool. The denial (deniedBy, policy, policyDigest and message) is recorded on the session as a policy decision.

deniedBy carries the @id of the statement that refused; when several forbids match, their ids are joined with commas. Three reasons name no statement, because none decided:

deniedByMeaning
no-matching-permitNothing permitted the call: either no policy selects this tool, or no permit matched.
producer-failedA producer could not compute its fact.
evaluation-errorA Cedar statement could not be evaluated.
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: ToolPolicy
metadata:
name: refund-limits
namespace: production
spec:
selector:
registry: customer-tools
tools:
- process_refund
producers:
- name: overRefundLimit
type: cel
cel: 'double(body.amount) > 500.0'
- name: missingReason
type: cel
cel: '!has(body.reason) || body.reason == ""'
- name: customerIsBanned
type: cel
cel: 'has(body.customer_status) && body.customer_status == "banned"'
policy: |
// The grant. Cedar is default-deny, so without this nothing runs.
@id("permit-refunds")
permit (principal, action == Action::"invoke",
resource in Registry::"customer-tools");
// principal.claims is keyed by lowercased claim name. A forbid gates the
// call even when another policy permits it.
@id("require-claims")
@message("The team and customer_id claims are required")
forbid (principal, action == Action::"invoke", resource)
unless {
principal.claims has team &&
principal.claims has customer_id
};
@id("max-refund-amount")
@message("Refund amount exceeds the $500 limit")
forbid (principal, action == Action::"invoke", resource)
when { context.overRefundLimit };
@id("require-reason")
@message("A reason is required for refund requests")
forbid (principal, action == Action::"invoke", resource)
when { context.missingReason };
@id("block-banned-customers")
@message("Refunds are not available for this account")
forbid (principal, action == Action::"invoke", resource)
when { context.customerIsBanned };
headerInjection:
- header: X-Tenant-Id
cel: 'headers["X-Omnia-Claim-Customer_id"]'
- header: X-Audit-Source
value: "policy-broker"
mode: enforce
onFailure: deny

Expected status after reconciliation:

status:
phase: Active
# One permit plus four forbids.
ruleCount: 5
advisoryCount: 0
observedGeneration: 1
conditions:
- type: Compiled
status: "True"
reason: PolicyCompiled
message: "5 policy statements compiled successfully"
- type: HeaderRefsCanonical
status: "True"
reason: HeaderRefsCanonical
message: "all header references are canonical"
- type: Ready
status: "True"
reason: PolicyValidated
message: "policy is active"