Your first agent policy, in YAML

"Policy as code" usually implies learning a policy language before you can express the first rule, which is a bad trade when the first rule is "don't refund more than $200 without a human." You can skip that step and still end up with real Rego underneath.

By Louis Bryson · 7 min read · Updated

Most agent guardrails start life as an if statement somewhere in a tool wrapper. That works until there are nine of them scattered across four files, nobody can answer "what is this agent actually allowed to do," and the rules are only reviewable by reading the code that implements them.

Policy as code fixes that by making the rules a separate, reviewable, testable artifact. The usual objection is the up-front cost: Rego is a real language with a real learning curve, and it's a lot to take on before you've expressed a single rule. So don't start there.

The whole policy

Here's a complete, working policy for a customer-support agent. Five rules, no Rego.

yamlpolicies/policy.yaml
version: 1

rules:
  - name: allow_read_ops
    when:
      action: [get_customer, list_transactions]
      scope: read_customer
    then: allow

  - name: allow_small_refund
    when:
      action: approve_refund
      role: [support_agent, manager]
      scope: approve_refund
      args:
        amount: { lte: 200 }
    then: allow

  - name: review_mid_refund
    when:
      action: approve_refund
      args:
        amount: { gt: 200, lte: 1000 }
    then: hitl
    reason: "Refunds over $200 need a human reviewer"

  - name: block_large_refund
    when:
      action: approve_refund
      args:
        amount: { gt: 1000 }
    then: deny
    reason: "Refunds over $1000 require manager approval"

  - name: block_shell_access
    when:
      action: run_shell_command
    then: deny
    reason: "Shell access is prohibited"

That's readable by someone who has never seen this tool before, which is most of the value. A support lead can review the refund thresholds without being able to write Rego, and an auditor can read what the agent is permitted to do without reading Python.

Three outcomes, not two

The then: field is where most of the design lives, and the middle option is the one people miss.

OutcomeWhat happensUse for
allowCall proceedsRoutine actions within the agent's remit
hitlAgent pauses; a human approves or rejects; outcome recordedExpensive but legitimate — most of the interesting cases
denyHard block; the model cannot override itThings that are never acceptable, ever

A policy that only uses allow and deny forces every judgement call into a binary at authoring time. Real operations don't work that way — a $500 refund isn't obviously fine or obviously forbidden, it's something a person should look at. Routing it beats guessing.

Conditions you can express

The args: block compares fields on the tool call's actual arguments, which is what separates this from a permission list. An agent can be allowed to call approve_refund and still be denied approve_refund(amount=50000).

OperatorMeaning
gt / gte / lt / lte / eqNumeric and string comparison
in / not_inValue is (or isn't) in a list
contains / not_containsString does (or doesn't) contain a substring

Compile, validate, test

bash
kitelogik compile policies/policy.yaml   # -> policies/policy.rego
kitelogik validate                       # opa check on every *.rego
kitelogik test                           # opa test policies/

The generated Rego is a normal file you can read, diff, and review. That matters more than it sounds: the YAML is the authoring surface, but the Rego is the artifact, and being able to see exactly what your rules became is what keeps the abstraction honest.

Tests are where policy-as-code earns its keep, because a policy is exactly the kind of thing that silently stops matching after an unrelated edit.

regopolicies/financial_test.rego
package kitelogik.financial_test

import data.kitelogik.financial

test_small_refund_is_allowed if {
    financial.allow with input as {
        "action": "approve_refund",
        "args": {"amount": 50},
        "context": {
            "user_role": "support_agent",
            "session_scopes": ["approve_refund_under_100"],
        },
    }
}

test_large_refund_is_denied if {
    not financial.allow with input as {
        "action": "approve_refund",
        "args": {"amount": 5000},
        "context": {
            "user_role": "support_agent",
            "session_scopes": ["approve_refund_under_100"],
        },
    }
}

For a single ad-hoc question — "would this event be allowed?" — kitelogik check takes the event as JSON and returns the full decision without starting an agent:

bash
kitelogik check '{
  "action": "read_file",
  "resource_path": "/etc/passwd",
  "context": {
    "session_id": "s1",
    "user_role": "support",
    "session_scopes": ["read_customer"]
  }
}'

Where YAML stops

The compiler deliberately doesn't generate three categories of rule, and the reason is worth understanding: each one reasons about relationships between events rather than about a single event's fields.

  • Delegation cascades — rules referencing parent_token_id and delegation_depth, i.e. the shape of the agent tree
  • Plan evaluation — rules that count step types or check invariants across a multi-step plan
  • Data classification flow — constraints on how classified data moves between events

A DSL that covered those would be most of Rego with worse error messages. So for those, you write Rego — and it's less alarming than it looks:

regopolicies/agent_lifecycle.rego
package kitelogik.agent_lifecycle

import future.keywords.if
import future.keywords.in
import future.keywords.every

default allow := false
default deny := false

allow if {
    input.event_type == "agent.spawn"
    input.context.delegation_depth <= 2
    every cap in input.requested_capabilities {
        cap in input.context.session_scopes
    }
}

deny if {
    input.event_type == "agent.spawn"
    input.context.delegation_depth > 2
}

Every policy file starts with default allow := false. Deny-by-default isn't a setting — it's the first line, and everything after it is an explicit grant.

That specific policy is doing real work: the subset rule is what stops privilege being manufactured through delegation.

A reasonable first hour

Start from a template rather than a blank file — the package ships starters for financial refunds, healthcare PHI access, and code-execution restrictions in kitelogik/policy_templates/. Copy one in, edit it down to your actual actions, write two tests (one thing that should pass, one that should be blocked), and run kitelogik test.

Then leave it alone until it denies something you expected to work. That moment is the useful one — it's when you find out whether the policy matches your mental model, and it's much cheaper to discover in development than in an incident.

Frequently asked questions

Do I need to learn Rego to write an agent policy?

Not to start. The YAML DSL covers action allowlists and denylists, argument thresholds, role and scope checks, and the allow / human-review / deny outcome for each rule. `kitelogik compile` turns it into Rego for you, and you never name a Rego package. You drop into hand-written Rego when you need delegation cascades, plan evaluation, or data-classification flow — things the compiler deliberately does not generate.

What is the difference between deny and hitl?

`then: deny` is a hard block the model cannot override — the call never executes. `then: hitl` is a soft deny that routes the action to a human reviewer: the agent pauses, a person decides, and the outcome is recorded either way. Use deny for things that are never acceptable and hitl for things that are expensive but legitimate.

Do I need an OPA server running?

You need a policy engine, but not necessarily a separate server. Rego can be evaluated in-process with the embedded engine, or pushed to a remote OPA server if you already operate one. The same policies work either way. `kitelogik init` scaffolds a Docker Compose file that runs OPA locally with --watch, so edits to policies hot-reload without restarting.

How do I test a policy before shipping it?

Rego has first-class unit testing. Write a `*_test.rego` file next to your policy asserting what should be allowed and denied, then run `kitelogik test` (internally `opa test policies/`). For one-off checks, `kitelogik check` takes a single event as JSON and returns the full decision without running an agent at all.

Where do the policy files live?

In a `policies/` directory in your repo — `kitelogik init` creates one. Compiled rules land in the `kitelogik.userpolicy` package, which the core bundle's main.rego aggregates alongside the built-in security, delegation, and HITL policies. Your rules compose with the shipped ones rather than replacing them.

Louis Bryson
Founder & maintainer, Kite Logik

Engineer focused on production AI agent infrastructure and policy-as-code. Maintains Kite Logik, the open-source OPA/Rego governance layer for Python agents.

Connect on LinkedIn