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.
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.
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.
| Outcome | What happens | Use for |
|---|---|---|
allow | Call proceeds | Routine actions within the agent's remit |
hitl | Agent pauses; a human approves or rejects; outcome recorded | Expensive but legitimate — most of the interesting cases |
deny | Hard block; the model cannot override it | Things 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).
| Operator | Meaning |
|---|---|
gt / gte / lt / lte / eq | Numeric and string comparison |
in / not_in | Value is (or isn't) in a list |
contains / not_contains | String does (or doesn't) contain a substring |
Compile, validate, test
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.
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:
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_idanddelegation_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:
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.