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.

Zero-trust policy enforcer architecture Two crate boundaries side by side. On the left, agentcy-api owns the request path: auth_middleware sets a UserContext, policy_middleware calls into the enforcer for an allow/deny decision, the route handler runs only if allowed, and inside the agent loop ToolPolicyChecker re-checks every tool call. On the right, agentcy-policy owns the PolicyEnforcer itself: a regorus Rego engine, a Tokio RwLock policy set, an mpsc audit writer that drains to PostgreSQL, and an AtomicBool master switch that can short-circuit every evaluation to allow during incidents. agentcy-api Request path HTTP request POST /api/v1/chat/conversations/:id/messages auth_middleware HS256 (local) or RS256 (OIDC) → sets UserContext POLICY GATE policy_middleware derive action from (method, path) → e.g. "chat.send" request decision allow / deny + reasons allow deny route handler streams response SSE chunks → caller 403 Forbidden reasons in error.body.policy inside loop PER-TOOL GATE ToolPolicyChecker re-checks every tool call before execution tool decision agentcy-policy Decision engine AtomicBool · enforce_flag Master switch · false short-circuits to allow PolicyEnforcer Arc<RwLock<Option<…>>> in AppState regorus engine Rego rule eval deny[msg] { ... } policy set Tokio RwLock hot-reload · swap audit writer mpsc channel · async drain subject · action · resource · decision · reason deny[msg] from any rule → deny; otherwise allow. async append policy_audit_log PostgreSQL · append-only Queryable from /policies/audit · retained per org policy Default-allow · OPA Rego rules define exclusions · runtime toggle without restart
agentcy-api routes flow through policy_middleware (and ToolPolicyChecker inside the agent loop) and call the agentcy-policy PolicyEnforcer; deny decisions return 403 and write to the audit log.

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.