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.
API version
Section titled “API version”apiVersion: omnia.altairalabs.ai/v1alpha1kind: ToolPolicyShort name: tp (e.g. kubectl get tp).
Spec fields
Section titled “Spec fields”selector
Section titled “selector”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.
| Field | Type | Required | Description |
|---|---|---|---|
registry | string | Yes | Name of the ToolRegistry to match. |
tools | []string | No | Specific tool names to match. If empty, applies to all tools in the registry. |
spec: selector: registry: customer-tools tools: - process_refund - issue_creditControls how the policy is applied.
| Value | Description |
|---|---|
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. |
audit | The 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. |
onFailure
Section titled “onFailure”Defines behavior when evaluation encounters an error: a producer that fails to compute its fact, or a Cedar statement that cannot be evaluated.
| Value | Description |
|---|---|
deny | (Default) Deny the request, with deniedBy set to producer-failed or evaluation-error. |
allow | Treat 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.
producers
Section titled “producers”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.
| Field | Type | Description |
|---|---|---|
name | string | The key the fact appears under in context. Must be unique, and must not shadow a built-in. |
type | string | cel: the broker evaluates the expression in-process. |
cel | string | The 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"))Built-in context
Section titled “Built-in context”The broker supplies these for every request, so a policy works with no producers declared:
| Key | Type | Meaning |
|---|---|---|
context.argBytes | Long | JSON-encoded size in bytes of the tool call’s arguments. 0 when the call has no arguments. |
context.argNames | Set<String> | Top-level argument names present. |
context.hasAttachments | Bool | true 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.
policy
Section titled “policy”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 };Annotations
Section titled “Annotations”| Annotation | Effect |
|---|---|
@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. |
What a policy can read
Section titled “What a policy can read”| Cedar | Built from | Trust |
|---|---|---|
principal | OmniaPrincipal::"<subject>", parents Workspace:: / Agent::, attributes origin, subject, endUser, workspace, agent, claims | verified by the auth chain |
action | Action::"invoke" | fixed |
resource | Tool::"<registry>::<tool>", parent Registry::"<registry>" | resolved by the runtime |
context | built-ins plus one entry per producer | derived 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.
Schema validation
Section titled “Schema validation”The controller validates spec.policy against a Cedar schema it generates from
the policy’s own declarations. You do not write the schema.
| Part | Declared as |
|---|---|
| Entity types | OmniaPrincipal in [Workspace, Agent], Workspace, Agent, Registry, Tool in Registry |
| Action | invoke, applying to principal OmniaPrincipal and resource Tool |
principal attributes | origin, subject, endUser, workspace, agent: String. claims: a record with one optional String attribute per claim name the policy reads |
context | argBytes: Long, argNames: Set<String>, hasAttachments: Bool, and one attribute per producer |
| Producer types | CEL 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.piiscoreorcontext has piiscorebeside a producer namedpiiScore; - 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
istest.
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:
| Rejected | Rewrite |
|---|---|
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.
headerInjection
Section titled “headerInjection”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).
| Field | Type | Required | Description |
|---|---|---|---|
header | string | Yes | HTTP header name to inject. |
value | string | Conditional | Static header value. Mutually exclusive with cel. |
cel | string | Conditional | CEL 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"]'Status fields
Section titled “Status fields”| Value | Description |
|---|---|
Active | The Cedar policy, every producer and every header-injection expression compiled. |
Error | Something 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.
ruleCount
Section titled “ruleCount”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.
advisoryCount
Section titled “advisoryCount”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.
contentDigest
Section titled “contentDigest”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.
conditions
Section titled “conditions”| Type | Meaning |
|---|---|
Ready | True (reason PolicyValidated) when the policy compiled; False (reason CompileError) otherwise. |
Compiled | True (reason PolicyCompiled, message N policy statements compiled successfully) or False (reason CompileError, message carrying the compiler error). |
HeaderRefsCanonical | Advisory. 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. |
observedGeneration
Section titled “observedGeneration”The most recent .metadata.generation observed by the controller.
Print columns
Section titled “Print columns”When using kubectl get toolpolicies, the following columns are displayed:
| Column | Source |
|---|---|
| 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 |
Denial response format
Section titled “Denial response format”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:
deniedBy | Meaning |
|---|---|
no-matching-permit | Nothing permitted the call: either no policy selects this tool, or no permit matched. |
producer-failed | A producer could not compute its fact. |
evaluation-error | A Cedar statement could not be evaluated. |
Complete example
Section titled “Complete example”apiVersion: omnia.altairalabs.ai/v1alpha1kind: ToolPolicymetadata: name: refund-limits namespace: productionspec: 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: denyExpected 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"Related resources
Section titled “Related resources”- Policy Engine Architecture — conceptual overview
- Configure Tool Policies — operational guide
- Securing Agents with Policies — end-to-end tutorial