Skip to content

Zero-Trust Policies

Security page — policies tab with a list of enabled rules, roles, and a recent deny decision at the top.

Agentcy's policy layer is a default-allow model where any number of OPA/Rego deny rules can block an action. It runs in two places:

  1. HTTP middleware — policies evaluate after auth, before the route handler.
  2. Agent loop — policies evaluate before every tool call.

Every decision is audited. Policies are opt-in (features.policies), but once on they can't be bypassed by any client — including the UI, the CLI, the SDK, or worker callbacks.

Related:

What policies can see

Every evaluation gets an input object with a consistent shape:

json
{
  "subject": {
    "user_id": "usr_...",
    "org_id":  "org_...",
    "role":    "admin",
    "channel": "ui"
  },
  "action": "chat.tool.execute",
  "resource": {
    "connector": "github",
    "tool":      "github.create_pull",
    "tool_effect": "write"
  },
  "context": {
    "approval_granted": false,
    "ip":               "10.0.0.7",
    "realm":            "development",
    "request_id":       "req_..."
  },
  "env": {
    "now":               "2026-04-24T12:00:00Z",
    "instance_mode":     "self-hosted"
  }
}

Action strings follow a <domain>.<verb> convention derived from the HTTP method + path (e.g. POST /api/v1/sources/:id/syncsource.sync). Tool calls use chat.tool.execute.

The full list of derivable actions lives in agentcy-api::policy_middleware.

Default policies

When you enable policies for the first time, Agentcy seeds four starter bundles you can edit or disable:

  1. agentcy.rego — cross-cutting: deny if subject.user_id is unknown, deny if context.request_id is missing.
  2. connectors.rego — require approval for any tool_effect == "write" unless subject.role in ["admin","owner"].
  3. tools.rego — deny specific dangerous tools for subject.role == "viewer".
  4. api.rego — deny admin routes for non-admin roles; deny DELETE on /sources outside business hours.

All four live in backend/crates/agentcy-policy/starter/ as embedded assets. The API copies them into the DB on first boot when policies are enabled.

Anatomy of a deny rule

Rego functions named deny[msg] are evaluated for their set of messages. Any non-empty deny set blocks the action and returns a 403 (REST) or an error event (agent loop).

rego
package agentcy

# Block write tools on chat if no approval was granted, except admins.
deny[msg] {
  input.action == "chat.tool.execute"
  input.resource.tool_effect == "write"
  not input.context.approval_granted
  input.subject.role != "admin"
  input.subject.role != "owner"
  msg := sprintf("write tool %q requires approval", [input.resource.tool])
}

Rules can be as narrow or broad as you want. Because the model is default-allow, you are always writing exclusions, not allow-lists.

The enforcer

agentcy-policy::PolicyEnforcer wraps a regorus engine, a Tokio RwLock policy set, and an audit writer (mpsc channel → Postgres). It's held in AppState as an Arc<RwLock<Option<…>>> so policies can be turned on, swapped, or turned off at runtime without restart.

┌──────── agentcy-api ─────────┐    ┌───────── agentcy-policy ────────┐
│                              │    │                                 │
│  auth_middleware             │    │  PolicyEnforcer (Arc)           │
│       │                      │    │    ├─ regorus engine             │
│       ▼                      │    │    ├─ policy set (RwLock)        │
│  policy_middleware ──────────┼──► │    ├─ audit writer (mpsc → PG)   │
│       │ allow/deny+reasons   │    │    └─ AtomicBool: enforce_flag   │
│       ▼                      │    │                                 │
│  route handler               │    └─────────────────────────────────┘
│       │                      │
│       ▼                      │
│  ToolPolicyChecker  ─────────┼───► same PolicyEnforcer
│   (inside agent loop)        │
└──────────────────────────────┘

The AtomicBool is the master switch. Setting it to false (from Settings → Security → Zero-Trust toggle) short-circuits every evaluation to allow — useful during incidents when the policy set itself is broken.

Where policies come from

  • DB — authoritative. Admins edit via PUT /api/v1/policies/:id or the UI. Changes are atomic (no partial policy set loaded).
  • Sources — optional: a policy can link to a Git repo or HTTP URL. POST /api/v1/policies/sources/:id/sync re-fetches and revalidates.
  • Tests — each policy can carry one or more test cases (input → expected deny). The API runs them in CI via POST /api/v1/policies/:id/test before enabling.

Audit log

Every decision is audited. An entry captures: decision (allow/deny), the full input, the matching rules, the policy id + version, latency, request id, user id, ip. See Audit Log.

Query it:

bash
curl -s "http://localhost:8080/api/v1/policies/audit?limit=50&decision=deny" \
  -H "authorization: Bearer $TOKEN" | jq

Entries live in policy_audit_log (Postgres) and are retained per POLICY_AUDIT_RETENTION_DAYS (default 90).

Roles & permissions

Policies evaluate against the authenticated subject. The role system lives in agentcy-core::tenant::permissions and currently defines 15 permissions grouped into three default roles:

RoleRough shape
ownerfull access; cannot be deny-listed by default rules.
adminorg config + connectors + policies; cannot change billing.
memberread graph, run read tools, request approvals for writes.
viewerread-only everything.

These are defaults; Rego can tighten further (e.g. "member cannot execute any tool from aws connector").

Turning policies on

env
AGENTCY_FEATURES_POLICIES=true

Restart the API. The first request after boot will load the starter rules from DB (seeded by migration 007_zero_trust_policies.sql). You'll see the full policy set in Settings → Security.

Turning policies off

  • Runtime: flip the toggle in Settings (writes false to the AtomicBool). No restart.
  • Permanent: unset AGENTCY_FEATURES_POLICIES. The policy_middleware layer is not installed on boot.

Both paths keep the audit log intact for retention.

Gotchas

  • Tokens are not subjects. Worker daemons (auth'd with WORKER_TOKEN) and pipeline agent runs (auth'd with pipeline JWT) produce subjects with role = "system". Make sure your deny rules don't accidentally block them.
  • Missing context fields return undefined in Rego, not an error. Use with input.field as value { … } in tests; guard with object.get(input.context, "field", default) in rules.
  • Hot-reload is per-org. Policy changes only apply to requests from that org — other orgs keep their own policy set in-memory.

Next

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