Skip to content

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.

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.

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

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:

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:

Terminal window
kubectl apply -f support-agent-tool-access.yaml

Check that it compiled:

Terminal window
kubectl get toolpolicies -n production
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:

Terminal window
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:

Terminal window
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"}}
}'
{"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.

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:

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:

Terminal window
kubectl apply -f refund-guardrails.yaml
kubectl get toolpolicies -n production
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

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

Terminal window
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"}}
}'
{"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:

Terminal window
kubectl logs -n production deploy/support-agent -c policy-broker | grep policy_decision

Now a compliant refund:

Terminal window
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"}}
}'
{"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.

Change the mode in refund-guardrails.yaml:

spec:
mode: enforce

Apply it and check the mode:

Terminal window
kubectl apply -f refund-guardrails.yaml
kubectl get toolpolicies -n production
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:

{
"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. The OIDC validator passes every claim except the subject and end-user claims through under its own name.