Skip to content

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=true

Restart. 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
FileWhat it does
agentcy.regoCore invariants — user_id present, request_id populated.
connectors.regoDefault write/destructive tools need approval unless role=admin/owner.
tools.regoPer-tool denies for the viewer role.
api.regoAdmin 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.agentcy virtual document, so every file should live under package agentcy.*.
  • deny[msg] — a set-producing rule. Any non-empty deny from 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" | jq

It carries the full input the engine saw — reproducible in the test endpoint.

Gotchas

  • Missing keys evaluate to undefined, not false. input.context.approval_granted is undefined if the field isn't set — wrap with object.get(input.context, "approval_granted", false).
  • Set ordering isn't deterministic. deny is 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.

Next

Built by AgentcyLabs. For in-house deployment or Agentcy Cloud (PaaS) access, visit agentcylabs.com.