# ToolPolicy CRD

:::note[Enterprise]
ToolPolicy is an Enterprise feature. See [Licensing](/explanation/platform/licensing/) for details.
:::

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](/explanation/security/policy-engine/) for the full PDP/PEP model.

## API version

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

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

## Spec fields

### `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. |

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

### `mode`

Controls 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](#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 `forbid`s, never `no-matching-permit`, so a forbid-only guardrail can be trialled on its own. |

### `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`

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(...)`. |

```yaml
producers:
  - name: containsRawStatement
    type: cel
    cel: >-
      has(body.evidence) && body.evidence.exists(e,
        e.type.matches("(?i)^bank[ _-]?statement"))
```

#### 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&lt;String&gt; | 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`

Required. [Cedar](https://docs.cedarpolicy.com/) 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.

```yaml
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

| 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

| 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

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.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:

| 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`.

:::caution[Permits are additive]
A condition inside a `permit` does not refuse a call that another policy
permits. Only a `forbid` refuses.
:::

:::caution[Only `permit` grants]
`headerInjection` is a post-decision action, not a grant. A policy carrying only
that permits nothing, and its tools stay denied.
:::

### `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`. |

```yaml
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

### `phase`

| 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](#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](#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`

Number of compiled Cedar **statements** — `permit`s plus `forbid`s. 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`

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`

`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`

| 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`

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

## 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

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:

```json
{
  "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
`forbid`s 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

```yaml
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:

```yaml
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

- [Policy Engine Architecture](/explanation/security/policy-engine/) — conceptual overview
- [Configure Tool Policies](/how-to/security/configure-tool-policies/) — operational guide
- [Securing Agents with Policies](/tutorials/securing-agents/) — end-to-end tutorial