# Securing agents with policies

In this tutorial we secure a customer service agent step by step. We block a tool, enforce business rules that read the caller's identity claims, and check everything in audit mode before enforcing.

## What we will build

The `support-agent` agent uses a `customer-tools` ToolRegistry with four tools: `lookup_order`, `check_status`, `process_refund` and `delete_account`. We want the agent to look up orders and process refunds, but never delete accounts. Refunds are capped at $500, need a reason, and need the caller's `team` and `customer_id` claims.

In production the runtime asks the policy-broker sidecar for a decision before every tool call, sending the call's arguments and the identity claims the facade verified. We play the runtime's part: we port-forward to the sidecar and send it the same `POST /v1/decision` requests, so every result below comes straight from the broker we are configuring and no token issuer is needed.

```mermaid
graph LR
    YOU["curl (kubectl port-forward)"] -->|"POST /v1/decision: headers, body, identity"| BROKER["policy-broker sidecar :8090"]
    BROKER -->|"allow or deny + injectedHeaders"| YOU
    TP[ToolPolicy resources] -->|watched| BROKER
```

## Prerequisites

- An Omnia cluster installed with `enterprise.enabled=true`, which installs the ToolPolicy CRD and injects the policy-broker sidecar into agent pods.
- The `support-agent` AgentRuntime and the `customer-tools` ToolRegistry deployed in the `production` namespace.
- `curl` on your workstation.

## Step 1: block a tool

A broker denies every call that no policy permits. We grant the whole registry, then forbid `delete_account`. A `forbid` overrides any `permit`.

Save this as `support-agent-tool-access.yaml`:

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: ToolPolicy
metadata:
  name: support-agent-tool-access
  namespace: production
spec:
  selector:
    registry: customer-tools
  mode: enforce
  policy: |
    @id("permit-customer-tools")
    permit (principal, action == Action::"invoke",
            resource in Registry::"customer-tools");

    @id("blocklisted-tool")
    @message("delete_account is not callable by support agents")
    forbid (principal, action == Action::"invoke",
            resource == Tool::"customer-tools::delete_account");
```

Apply it:

```bash
kubectl apply -f support-agent-tool-access.yaml
```

Check that it compiled:

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

```text
NAME                        READY   REGISTRY         MODE      PHASE    RULES   AGE
support-agent-tool-access   True    customer-tools   enforce   Active   2       10s
```

`Active` with `2` rules means both statements compiled. A ToolPolicy selects tools by registry, so it applies to every agent that uses `customer-tools`.

Now we ask the broker about `delete_account`. In a second terminal, forward the sidecar's port and leave it running:

```bash
kubectl port-forward -n production deploy/support-agent 8090:8090
```

Back in the first terminal, define a small helper that posts a decision request. The request names the registry and tool in headers, and carries the call's arguments in `body` and the caller's claims in `identity`:

```bash
decide() { curl -s http://localhost:8090/v1/decision -d "$1"; echo; }

decide '{
  "headers": {"X-Omnia-Tool-Registry": "customer-tools", "X-Omnia-Tool-Name": "delete_account"},
  "body": {},
  "identity": {"subject": "alice", "claims": {"team": "support", "customer_id": "c-42"}}
}'
```

```json
{"allow":false,"deniedBy":"blocklisted-tool","policy":"support-agent-tool-access","policyDigest":"sha256:…","message":"delete_account is not callable by support agents","mode":"enforce","wouldDeny":false,"injectedHeaders":null}
```

The broker refuses the call and names the statement that did it. The runtime aborts the tool call on `"allow":false`, returns `message` to the model as the tool result, and records the denial on the session.

## Step 2: add business rules

Two CEL producers compute facts from the tool call's arguments, and Cedar statements read them as `context.<name>`. The `permit` grants `process_refund`. The `forbid` statements refuse a call that lacks the `team` or `customer_id` claim, exceeds $500, or has no reason. `principal.claims` holds the identity's claims keyed by lowercased claim name. The header injection copies the `team` claim, which the runtime also passes to the broker as the `X-Omnia-Claim-Team` header, into `X-Processed-By`.

Save this as `refund-guardrails.yaml`:

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: ToolPolicy
metadata:
  name: refund-guardrails
  namespace: production
spec:
  selector:
    registry: customer-tools
    tools:
      - process_refund

  producers:
    - name: overRefundLimit
      type: cel
      cel: 'has(body.amount) && double(body.amount) > 500.0'
    - name: missingReason
      type: cel
      cel: '!has(body.reason) || body.reason == ""'

  policy: |
    @id("permit-refunds")
    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("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 };

  headerInjection:
    - header: X-Processed-By
      cel: 'headers["X-Omnia-Claim-Team"]'

  mode: audit
  onFailure: deny
```

Apply it and check that it compiled:

```bash
kubectl apply -f refund-guardrails.yaml
kubectl get toolpolicies -n production
```

```text
NAME                        READY   REGISTRY         MODE      PHASE    RULES   AGE
refund-guardrails           True    customer-tools   audit     Active   4       10s
support-agent-tool-access   True    customer-tools   enforce   Active   2       5m
```

## Step 3: validate in audit mode

An `audit` policy reports what it would refuse without blocking anything. We send a $600 refund with a reason, which breaks the cap:

```bash
decide '{
  "headers": {"X-Omnia-Tool-Registry": "customer-tools", "X-Omnia-Tool-Name": "process_refund",
              "X-Omnia-Claim-Team": "support"},
  "body": {"amount": 600, "reason": "damaged"},
  "identity": {"subject": "alice", "claims": {"team": "support", "customer_id": "c-42"}}
}'
```

```json
{"allow":true,"deniedBy":"max-refund-amount","policy":"support-agent-tool-access","policyDigest":"sha256:…","message":"Refund amount exceeds the $500 limit","mode":"enforce","wouldDeny":true,"injectedHeaders":{"X-Processed-By":"support"}}
```

The call is allowed, `wouldDeny` is `true`, and `deniedBy` names the statement that would have refused it. The `mode` and `policy` fields name the enforcing policy that granted the call, because an audit policy never grants. The runtime records a `would_deny` decision on the session for the same case. `injectedHeaders` carries `X-Processed-By` with the caller's team.

The broker also logs the decision. Its `policy_decision` line carries the same fields:

```bash
kubectl logs -n production deploy/support-agent -c policy-broker | grep policy_decision
```

Now a compliant refund:

```bash
decide '{
  "headers": {"X-Omnia-Tool-Registry": "customer-tools", "X-Omnia-Tool-Name": "process_refund",
              "X-Omnia-Claim-Team": "support"},
  "body": {"amount": 50, "reason": "damaged"},
  "identity": {"subject": "alice", "claims": {"team": "support", "customer_id": "c-42"}}
}'
```

```json
{"allow":true,"deniedBy":"","policy":"support-agent-tool-access","policyDigest":"sha256:…","message":"","mode":"enforce","wouldDeny":false,"injectedHeaders":{"X-Processed-By":"support"}}
```

A compliant call has an empty `deniedBy`, `wouldDeny` is `false`, and the broker writes no `policy_decision` line for it.

:::tip
Keep audit mode on for a few hours of real traffic before you enforce.
:::

## Step 4: switch to enforce

Change the mode in `refund-guardrails.yaml`:

```yaml
spec:
  mode: enforce
```

Apply it and check the mode:

```bash
kubectl apply -f refund-guardrails.yaml
kubectl get toolpolicies -n production
```

```text
NAME                        READY   REGISTRY         MODE      PHASE    RULES   AGE
refund-guardrails           True    customer-tools   enforce   Active   4       1h
support-agent-tool-access   True    customer-tools   enforce   Active   2       1h
```

Send the $600 refund from step 3 again. The broker now refuses it, and the runtime aborts the tool call and returns the message to the model as the tool result:

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

The $50 refund still returns `"allow":true` with `X-Processed-By` set to `support`. A refund with no reason, or an `identity` without the `team` and `customer_id` claims, returns `"allow":false` with `deniedBy` set to `require-reason` or `require-claims`.

A real agent reaches the broker the same way, with the claims from a token the facade verified. To send a token with `team` and `customer_id` claims, configure `spec.externalAuth.oidc` as described in [Configure Agent Authentication](/how-to/security/configure-authentication/). The OIDC validator passes every claim except the subject and end-user claims through under its own name.

## Next steps

- Add Cedar statements for other tools in the registry.
- Create policies for other registries in the namespace.
- Read the [ToolPolicy reference](/reference/policies/toolpolicy/) for every field.
- Read the [Policy Engine Architecture](/explanation/security/policy-engine/) page for the decision model, and [Configure Tool Policies](/how-to/security/configure-tool-policies/) for operational tasks.