Appearance
Writing Rego Policies
Agentcy evaluates policies on two paths: HTTP middleware (before any route handler) and the agent loop (before every tool call). The engine is regorus (Rego embedded, pure-Rust, no external opa binary).
Conceptual model: Zero-Trust Policies.
Turn policies on
env
AGENTCY_FEATURES_POLICIES=trueRestart. On first boot, migration 007_zero_trust_policies.sql seeds four starter policies you can edit or disable.
Starter policies
Each is a .rego file stored in Postgres. You see them in Settings → Security → Policies and via:
bash
curl "http://…/policies" -H "authorization: Bearer $TOKEN" | jq| File | What it does |
|---|---|
agentcy.rego | Core invariants — user_id present, request_id populated. |
connectors.rego | Default write/destructive tools need approval unless role=admin/owner. |
tools.rego | Per-tool denies for the viewer role. |
api.rego | Admin routes require role=admin; some routes are business-hours-only. |
Anatomy of a rule
rego
package agentcy.connectors
# Block destructive tools unless policy-approved and role admin/owner.
deny[msg] {
input.action == "chat.tool.execute"
input.resource.tool_effect == "destructive"
not role_allows_destructive(input.subject.role)
msg := sprintf("destructive tool %q denied for role %q",
[input.resource.tool, input.subject.role])
}
role_allows_destructive(role) {
role == "admin"
}
role_allows_destructive(role) {
role == "owner"
}Structure:
- Package — namespaces the rules. Agentcy evaluates the
data.agentcyvirtual document, so every file should live underpackage agentcy.*. deny[msg]— a set-producing rule. Any non-emptydenyfrom any loaded rule blocks the action.- Helpers — boolean rules like
role_allows_destructive.
The input object
Full shape — use it as your guide:
json
{
"subject": {
"user_id": "usr_…",
"org_id": "org_…",
"role": "member",
"channel": "ui",
"external_id":"auth0|abc",
"api_key_id": null
},
"action": "chat.tool.execute",
"resource": {
"connector": "kubernetes",
"tool": "delete_pod",
"tool_effect": "destructive",
"route_method": "POST",
"route_path": "/api/v1/chat/conversations/:id/messages",
"realm": "infrastructure"
},
"context": {
"approval_granted": false,
"ip": "10.0.0.7",
"request_id": "req_…",
"conversation_id": "conv_…"
},
"env": {
"now": "2026-04-24T14:32:11Z",
"instance_mode": "self-hosted",
"org_tier": "team"
}
}On the HTTP path, resource.connector and resource.tool are absent; action is derived from (route_method, route_path) via the action map in agentcy-api::policy_middleware.
Common patterns
Approval-gated writes
rego
package agentcy.connectors
deny[msg] {
input.action == "chat.tool.execute"
input.resource.tool_effect in {"write","destructive"}
not input.context.approval_granted
not admin_or_owner(input.subject.role)
msg := sprintf("%s requires approval", [input.resource.tool])
}
admin_or_owner(role) { role == "admin" }
admin_or_owner(role) { role == "owner" }Deny a specific tool entirely
rego
deny[msg] {
input.resource.tool == "aws.terminate_ec2_instance"
msg := "aws.terminate_ec2_instance is disabled"
}Restrict by channel
rego
deny[msg] {
input.subject.channel == "whatsapp"
input.resource.tool_effect != "read"
msg := "only read tools over whatsapp"
}Business hours only
rego
deny[msg] {
input.action == "source.sync"
input.resource.connector == "hubspot"
t := time.parse_rfc3339_ns(input.env.now)
hour := time.clock(t)[0]
not (hour >= 9; hour < 18)
msg := "hubspot syncs only during business hours"
}Realm-scoped restriction
rego
deny[msg] {
input.resource.realm == "crm"
input.subject.role == "viewer"
input.action == "graph.cypher"
msg := "viewers cannot run Cypher in crm realm"
}Writing and testing
Create a policy:
bash
curl -X POST http://…/policies \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"name":"deny-aws-destruction",
"package":"agentcy.aws",
"rego":"package agentcy.aws\n\ndeny[msg] { input.resource.tool == \"aws.terminate_ec2_instance\"; msg := \"disabled\" }",
"enabled": false
}'Test with a sample input before enabling:
bash
curl -X POST "http://…/policies/$POL_ID/test" \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"input":{
"subject":{"role":"admin"},
"action":"chat.tool.execute",
"resource":{"tool":"aws.terminate_ec2_instance","tool_effect":"destructive"}
},
"expect":{"deny":["disabled"]}
}'The API returns {"passed": true|false, "actual":{…}, "expected":{…}}. CI can drive this via a simple loop to gate merges.
Enable once green:
bash
curl -X PATCH "http://…/policies/$POL_ID" -d '{"enabled":true}' \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json'Loading from a Git repo
For policy-as-code workflows, register a policy source:
bash
curl -X POST http://…/policies/sources \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"name":"main-rules",
"kind":"git",
"git":{"url":"https://github.com/acme/agentcy-policies.git","ref":"main","path":"rules/"}
}'
# Sync
curl -X POST "http://…/policies/sources/$SRC_ID/sync" -H "authorization: Bearer $TOKEN"Each pull revalidates all rules (Rego parse + tests) before swapping the in-memory set.
Debugging a deny
When a request fails with 403 policy_denied, the response includes:
json
{
"error":{
"code":"policy_denied",
"message":"destructive tool \"kubernetes.delete_pod\" denied for role \"member\"",
"policy_id":"pol_01…",
"rule":"agentcy.connectors.deny",
"audit_id":"aud_…"
}
}Grab the audit entry:
bash
curl "http://…/policies/audit?audit_id=aud_…" -H "authorization: Bearer $TOKEN" | jqIt carries the full input the engine saw — reproducible in the test endpoint.
Gotchas
- Missing keys evaluate to undefined, not false.
input.context.approval_grantedis undefined if the field isn't set — wrap withobject.get(input.context, "approval_granted", false). - Set ordering isn't deterministic.
denyis a set; don't rely on which message comes first. - Performance: keep regex at module scope, not per-rule. Compile once.
- Rego is declarative, not imperative. If you're tempted to write a big if/else, refactor into helper rules.