Appearance
Zero-Trust Policies

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:
- HTTP middleware — policies evaluate after auth, before the route handler.
- 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/sync → source.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:
agentcy.rego— cross-cutting: deny ifsubject.user_idis unknown, deny ifcontext.request_idis missing.connectors.rego— require approval for anytool_effect == "write"unlesssubject.role in ["admin","owner"].tools.rego— deny specific dangerous tools forsubject.role == "viewer".api.rego— deny admin routes for non-admin roles; denyDELETEon/sourcesoutside 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/:idor 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/syncre-fetches and revalidates. - Tests — each policy can carry one or more test cases (
input→ expecteddeny). The API runs them in CI viaPOST /api/v1/policies/:id/testbefore 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" | jqEntries 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:
| Role | Rough shape |
|---|---|
owner | full access; cannot be deny-listed by default rules. |
admin | org config + connectors + policies; cannot change billing. |
member | read graph, run read tools, request approvals for writes. |
viewer | read-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=trueRestart 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
falseto theAtomicBool). No restart. - Permanent: unset
AGENTCY_FEATURES_POLICIES. Thepolicy_middlewarelayer 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 withrole = "system". Make sure your deny rules don't accidentally block them. - Missing context fields return
undefinedin Rego, not an error. Usewith input.field as value { … }in tests; guard withobject.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
- How-To: Writing Rego Policies — templates, testing, common patterns.
- How-To: Audit Log — exports, SIEM integration.
- Agent Loop — how tool approvals and policies interact.