# Configure tool policies

Write a ToolPolicy that decides which tool calls an agent may make, test it in audit mode, then enforce it. The [ToolPolicy reference](/reference/policies/toolpolicy/) lists every field, and [Policy engine architecture](/explanation/security/policy-engine/) explains how the broker decides.

## Prerequisites

- Enterprise enabled in the chart (`enterprise.enabled: true`). Without a licence, Omnia runs in dev mode with every feature on. See [Licensing](/explanation/platform/licensing/).
- An AgentRuntime whose `spec.toolRegistryRef` names a deployed ToolRegistry, in the same namespace as the ToolPolicy.
- For claim-based rules: an AgentRuntime with `spec.externalAuth` configured, so verified token claims reach the broker. See [Configure agent authentication](/how-to/security/configure-authentication/).

## Write and apply a policy

1. Select the tools. Omit `tools` to select every tool in the registry.

   ```yaml
   spec:
     selector:
       registry: customer-tools
       tools:
         - process_refund
   ```

2. Add a producer for each fact the policy needs. A producer is a CEL expression that reads the tool call and returns a bool, string or int. The policy reads the result as `context.<name>`.

   ```yaml
   producers:
     - name: overRefundLimit
       type: cel
       cel: 'has(body.amount) && double(body.amount) > 500.0'
   ```

3. Write the Cedar policy. Start with the grant, then add `forbid` statements.

   ```yaml
   policy: |
     @id("permit-refunds")
     permit (principal, action == Action::"invoke",
             resource in Registry::"customer-tools");

     @id("max-refund-amount")
     @message("Refund amount exceeds the $500 limit")
     forbid (principal, action == Action::"invoke", resource)
     when { context.overRefundLimit };
   ```

   `@id` becomes `deniedBy` on the decision. `@message` reaches the model as the tool result, so state what a compliant call looks like.

   :::caution
   A ToolPolicy that selects a tool with no matching `permit` denies every call to it.
   :::

4. Set `mode: audit` and apply the manifest. An audit policy never blocks and never grants. The broker records each call it would have denied.

   ```yaml
   apiVersion: omnia.altairalabs.ai/v1alpha1
   kind: ToolPolicy
   metadata:
     name: refund-guardrails
     namespace: production
   spec:
     mode: audit
     # selector, producers and policy from the steps above
   ```

   ```bash
   kubectl apply -f refund-guardrails.yaml
   ```

   An audit policy with no `permit` reports only the calls a `forbid` matches. One that holds a `permit` also reports the calls it would not grant.

5. Read the broker log after some agent traffic. Illustrative output:

   ```json
   {"msg":"policy_decision","allowed":true,"deniedBy":"max-refund-amount","message":"Refund amount exceeds the $500 limit","mode":"audit","policy":"refund-guardrails","policyDigest":"sha256:<hex>"}
   ```

   `wouldDeny: true` appears in the decision returned to the runtime, not in this log line.

6. Change `mode` to `enforce` (the default) and apply again.

An applied edit or delete reaches running agents without a restart.

## Write producers

A producer's `cel` reads `body`, `headers`, `tool`, `governance` and `identity`. CEL string functions such as `startsWith` and `matches` are available.

Guard field access with `has()`. A producer that errors denies the call under the default `onFailure: deny`.

```yaml
producers:
  - name: overQuantityLimit
    type: cel
    cel: 'has(body.quantity) && int(body.quantity) > 100'
  - name: externalURL
    type: cel
    cel: 'has(body.url) && !body.url.startsWith("https://internal.")'
  - name: destructiveSQL
    type: cel
    cel: 'has(body.query) && body.query.matches("(?i)(DROP|DELETE|TRUNCATE)")'
```

Header keys are in canonical form, and every header the broker sees starts with `X-Omnia-`. Test for a key with `in` before you index it (illustrative provider name):

```yaml
producers:
  - name: usesLocalModel
    type: cel
    cel: '"X-Omnia-Provider" in headers && headers["X-Omnia-Provider"] == "ollama"'
```

The broker supplies `context.argBytes`, `context.argNames` and `context.hasAttachments` for every call, so a policy works with no producers. A producer cannot reuse those names. See [Built-in context](/reference/policies/toolpolicy/#built-in-context).

A producer yields a bool, int or string, and the controller must be able to tell which from the expression. Convert a bare field read: `int(body.quantity)`, not `body.quantity`. The controller then checks every `context.<name>` the policy reads against the producers and built-ins, and puts a policy that misspells a key or compares it with the wrong type in phase `Error`. Only a producer the policy reads needs a type the controller can tell. See [Schema validation](/reference/policies/toolpolicy/#schema-validation).

## Require JWT claims

Require claims with `forbid ... unless`. A `forbid` refuses the call whatever any other policy permits. A condition inside a `permit` does not.

```yaml
policy: |
  @id("permit-all")
  permit (principal, action == Action::"invoke", resource);

  @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("finance-only")
  @message("Only the finance team can use this tool")
  forbid (principal, action == Action::"invoke", resource)
  unless { principal.claims has team && principal.claims.team == "finance" };
```

`principal.claims` is keyed by the lowercased claim name, so write `team`, not `Team`. Quote a name that is not a Cedar identifier: `principal.claims has "customer-id"`, read as `principal.claims["customer-id"]`.

Claim values are strings. An array claim arrives comma-joined, and a nested object is dropped. `claimMapping` maps only `subject` and `endUser`; every other claim keeps its name.

:::caution[Claim headers use canonical casing]
A producer that reads `X-Omnia-Claim-*` headers must match the canonical form, where each hyphen-separated segment is title-cased. Claim `team` arrives as `X-Omnia-Claim-Team`, `customer-id` as `X-Omnia-Claim-Customer-Id`, and `customer_id` as `X-Omnia-Claim-Customer_id`. A key in another casing never matches. The operator sets `HeaderRefsCanonical: False` and emits a `Warning` event naming the key.
:::

Combine a fact with identity: the producer reports what was sent, and the policy decides who may send it.

```yaml
producers:
  - name: highValue
    type: cel
    cel: 'has(body.amount) && double(body.amount) > 1000.0'
policy: |
  @id("permit-all")
  permit (principal, action == Action::"invoke", resource);

  @id("high-value-finance-only")
  @message("Only the finance team can process amounts over $1000")
  forbid (principal, action == Action::"invoke", resource)
  when {
    context.highValue &&
    !(principal.claims has team && principal.claims.team == "finance")
  };
```

## Inject headers into upstream requests

Headers are added to the tool request only when the policy allows the call. Each rule sets exactly one of `value` or `cel`; a rule that sets both or neither puts the policy in phase `Error`.

```yaml
spec:
  headerInjection:
    - header: X-Policy-Version
      value: "v2"
    - header: X-Tenant-Id
      cel: 'headers["X-Omnia-Claim-Customer_id"]'
    - header: X-Audit-Trail
      cel: 'headers["X-Omnia-Agent-Name"] + "/" + headers["X-Omnia-Session-Id"]'
```

A header expression that errors does not change the decision. See [headerInjection](/reference/policies/toolpolicy/#headerinjection).

## Apply a policy to every tool in a registry

Omit `tools` from the selector. This example refuses any call with an oversized argument payload and needs no producer:

```yaml
spec:
  selector:
    registry: customer-tools
  policy: |
    @id("permit-registry")
    permit (principal, action == Action::"invoke",
            resource in Registry::"customer-tools");

    @id("argument-size-limit")
    @message("Arguments are limited to 64 KiB")
    forbid (principal, action == Action::"invoke", resource)
    when { context.argBytes > 65536 };
```

## Verify

List the policies:

```bash
kubectl get toolpolicies -n production
```

Each policy shows phase `Active`, and `RULES` counts its Cedar statements (the example in this guide has two). The columns are `NAME`, `READY`, `REGISTRY`, `MODE`, `PHASE`, `RULES`, `ADVISORIES` and `AGE`.

`ADVISORIES` counts the advisory checks that found a likely mistake, such as a non-canonical header key. An advisory never blocks the policy, so phase stays `Active`. Run `kubectl describe` to read each condition's reason and message. The dashboard shows the same advisories on the registry's **Policies** tab, and the registry's **Overview** shows a warning while any policy that selects the registry has an advisory.

If a policy shows phase `Error`, describe it to read the compile error:

```bash
kubectl describe toolpolicy refund-guardrails -n production
```

## Troubleshooting

### Every call to the tool is denied

The decision's `deniedBy` is `no-matching-permit`. No `permit` matched, or no ToolPolicy in the agent's namespace selects the tool's registry. Add a `permit` as in step 3. See [Denial response format](/reference/policies/toolpolicy/#denial-response-format).

### An edit did not take effect

`kubectl get toolpolicy` shows phase `Error`. A broker that already holds the previous version keeps enforcing it, so a policy still showing your old rule means the new text did not compile or failed schema validation. Fix the error shown by `kubectl describe`.

### Phase is `Error` with a `cedar schema validation` message

The text parses, but a statement does not match what the policy declares. A broker that holds the previous version keeps enforcing it until you fix the statement. The `Compiled` condition names the statement by its `@id` and the reason:

- `context key "piiscore" is not supplied by any producer or built-in`: the statement reads or tests a context key that nothing supplies. Check the spelling against `spec.producers[].name`; keys are case-sensitive.
- `entity type "User" does not exist`: an `is` test names a type the schema does not declare. Use `OmniaPrincipal`, `Workspace`, `Agent`, `Registry` or `Tool`.
- `the types Long and String are not compatible`: the statement compares a fact with a value of another type. An int producer is a `Long`; compare it with a number.
- `unable to guarantee safety of access to optional attribute`: the statement reads a claim without testing it first. Write `principal.claims has team && principal.claims.team == "finance"`. A test that holds only through `!` and `||` does not count: rewrite `!(principal.claims has team) || principal.claims.team == "x"` as `!(principal.claims has team) || (principal.claims has team && principal.claims.team == "x")`.
- `the types Agent and Workspace are not compatible`: a set mixes entity types, as in `principal in [Workspace::"w", Agent::"a"]`. Write `principal in Workspace::"w" || principal in Agent::"a"`.

See [Schema validation](/reference/policies/toolpolicy/#schema-validation).

### Phase is `Error` with a `spec.producers` message

A producer the policy reads has a result that is not a bool, int or string, or whose type is not known until the call. A bare field read such as `body.amount` is untyped: convert it, as in `int(body.amount)`.

### A policy is ignored for one agent

A broker compiles only the ToolPolicies whose `spec.selector.registry` matches its agent's `toolRegistryRef`. Check that the names match.

## Related pages

- [ToolPolicy reference](/reference/policies/toolpolicy/): every field, status and the denial format
- [Policy engine architecture](/explanation/security/policy-engine/): how the broker decides and how changes reach it
- [Securing agents with policies](/tutorials/securing-agents/): end-to-end tutorial