Policy Engine
Overview
Behavry uses Open Policy Agent (OPA) as its policy engine. Policies are written in Rego and stored in two places:
- Base policies (
policies/base/): checked into the repo, pushed to OPA at startup. These implement the core governance rules for all MCP server types. - Custom policies (database): created via the Policies UI, stored in PostgreSQL, synced to OPA when activated.
OPA runs as a separate sidecar container. The Behavry backend calls it via REST: POST /v1/data/behavry/authz.
In addition to OPA evaluation, the enforcement engine runs several pre-OPA checks in Python (blast radius limits, DLP scanning, baseline drift) that can deny or escalate requests before they ever reach OPA.
OPA Input Envelope
Every policy evaluation receives this input:
{
"agent": {
"id": "a1b2c3d4-...",
"roles": ["filesystem-reader"],
"permissions": ["filesystem:read", "database:read"],
"risk_tier": "medium"
},
"request": {
"tool_name": "read_file",
"action": "read",
"resource": "/home/projects/report.pdf",
"parameters": {"path": "/home/projects/report.pdf"},
"mcp_server": "filesystem",
"resource_count": 1
},
"context": {},
"requester": {
"id": "user@example.com",
"channel": "dashboard",
"verified": true
},
"workflow_session": null,
"delegation": null
}
These are populated by the enforcement engine from the agent's JWT claims, the parsed MCP tool call, and request headers.
Field Reference
| Field | Source | Description |
|---|---|---|
agent.* | JWT claims | Agent identity, roles, permissions, and risk tier |
request.* | MCP tool call | Tool name, action, resource path, parameters, and server type |
request.resource_count | Proxy engine | Number of affected resources (used by blast radius rules) |
context | Request metadata | Additional context (reserved for future use) |
requester | X-Requester-Id header / delegation token | Identity of the human or upstream agent that instructed this agent |
requester.verified | Delegation token chain | true when requester identity is cryptographically verified via a delegation token; false when caller-asserted only |
workflow_session | X-Workflow-Session JWT | Workflow context including causal depth, max depth, and permission ceiling. null for non-workflow requests |
delegation | X-Delegation-Token JWT | Delegation scope, effective permissions, and verification status. null for non-delegated requests |
OPA Output (Decision)
{
"result": "allow",
"policy": "filesystem.read",
"reason": ""
}
result | Meaning |
|---|---|
allow | Forward the tool call to the backend MCP server |
deny | Block immediately; return JSON-RPC error -32003 to agent |
escalate | Hold the request; wait for human approval via dashboard |
Policy Evaluation Order
The enforcement engine has a layered check sequence. Several checks run before OPA is consulted:
1. JWT validation (auth)
2. Session active check (DB)
2d. Workflow context validation (X-Workflow-Session JWT)
2e. Delegation token validation (X-Delegation-Token JWT)
3. Baseline cache check (tool drift — blocks if not in manifest)
4. DLP scan (blocks on critical severity)
4c. Delegation scope probe tracking
4d. Blast radius pre-check (Python-native — deny or escalate)
5. Policy exception check (standing allowlist — bypasses OPA if matched)
6. OPA evaluation (behavry/authz package)
The blast radius pre-check (step 4d) is notable because it runs entirely in Python before OPA is called. This avoids an unnecessary OPA round-trip for structurally dangerous operations like shallow-path deletes.
OPA itself uses a first-match-wins model via Rego's complete rule semantics with priority:
- Explicit deny rules (highest priority)
- Explicit escalate rules
- Explicit allow rules
- Generic RBAC fallback
- Default deny (no policy matched)
Blast Radius Pre-Check
Before OPA evaluation, the proxy engine runs a Python-native blast radius check that evaluates whether an action's scope is disproportionately large. This check applies five rules:
| Rule | Condition | Decision |
|---|---|---|
| Shallow delete | Delete action on a path with depth below minimum (default 3) | deny |
| Recipient limit | Message/email action with too many recipients (default 10) | escalate |
| Bulk threshold | Non-delete, non-message action affecting too many resources (default 50) | escalate |
| Config path write | Write action targeting system config paths (/etc, ~/.ssh, ~/.aws, etc.) | escalate |
| Protected file patterns | Any action on files matching protected patterns (MEMORY, SOUL, IDENTITY, .env) | escalate |
Deny results are returned immediately. Escalate results create a HITL hold via the escalation queue, and the engine waits for human resolution before proceeding.
Per-Tenant Configuration
All blast radius thresholds are configurable per tenant via the Admin API:
GET /api/v1/admin/blast-radius-- retrieve current configurationPATCH /api/v1/admin/blast-radius-- update thresholds
Configuration changes are synced to OPA as data at data.behavry.blast_radius_config, so both the Python pre-check and the OPA Rego policy (policies/base/blast_radius.rego) use the same thresholds.
When the blast radius check triggers, a BLAST_RADIUS_ESCALATION event is emitted to the audit log and event bus.
OPA Policy Modules
Beyond the base RBAC authorization rules at behavry/authz, Behavry ships several specialized OPA policy modules:
| Package | Path | Purpose |
|---|---|---|
behavry.authz | policies/base/ | Core RBAC, per-server rules (filesystem, database, Slack, GitHub, web) |
behavry.blast_radius | policies/base/blast_radius.rego | Blast radius limits (shallow delete, recipient cap, bulk threshold, config paths, protected files) |
behavry.delegation.policy | policies/delegation/delegation_policy.rego | Delegation scope enforcement: deny unverified tokens, escalate out-of-scope tools |
behavry.workflows.policy | policies/workflows/workflow_policy.rego | Workflow governance: deny depth exceeding max_depth, escalate tools outside permission ceiling |
behavry.inbound.injection_check | policies/inbound/injection_check.rego | Injection findings evaluation from inbound content scanning |
behavry.inbound.domain_trust | policies/inbound/domain_trust.rego | Content source trust tier enforcement |
behavry.requester | policies/requester/requester_policy.rego | Requester verification: block critical actions with unverified/absent requester, escalate high-risk actions |
behavry.authz.autogen | policies/autogen/ | Auto-generated policies from the Red Team Policy Automation loop |
Delegation Policy
The delegation policy evaluates every request carrying an X-Delegation-Token header:
- Allow when the delegation is verified and the tool is within
effective_permissions.tools - Deny when the delegation token validation failed (unverified)
- Escalate when the tool is not in the delegation's effective permissions (scope probe)
- Skip (allow) when no delegation context is present
Workflow Policy
The workflow policy evaluates requests carrying an X-Workflow-Session header:
- Deny when causal depth exceeds the workflow's
max_depth - Escalate when the tool is not in the workflow's permission ceiling
- Allow when all constraints are satisfied
- Skip (allow) when no workflow context is present
Requester Policy
The requester policy enforces identity verification based on risk tier:
- Block critical-tier actions with no requester identity or unverified requester
- Escalate high-tier actions with no requester identity or unverified requester
- Verified requesters (via delegation token chain) pass through for high-tier actions
Base Policy Structure
package behavry.authz
import rego.v1
# Default: deny if no rule matches
default decision := {"result": "deny", "reason": "No policy matched"}
# Deny rule (high priority via rule ordering)
decision := {
"result": "deny",
"reason": "Access to sensitive files is not permitted",
"policy": "filesystem.blocked_paths",
} if {
_is_filesystem_server
_fs_sensitive_path
}
# Allow rule
decision := {"result": "allow", "policy": "filesystem.read"} if {
_is_filesystem_server
input.request.tool_name in _fs_read_ops
"filesystem:read" in input.agent.permissions
not _fs_sensitive_path
}
# Escalate rule
decision := {
"result": "escalate",
"reason": "File deletion requires human approval",
"policy": "filesystem.escalate_delete",
} if {
_is_filesystem_server
input.request.tool_name in _fs_delete_ops
"filesystem:write" in input.agent.permissions
not _fs_sensitive_path
}
Built-in Policy Rules (Base Policies)
Filesystem Server
| Rule | Condition | Decision |
|---|---|---|
filesystem.blocked_paths | Path contains .env, .ssh, .aws, id_rsa, credentials, secrets, etc. | deny |
filesystem.read | tool in {list_directory, read_file, search_files}, has filesystem:read, non-sensitive path | allow |
filesystem.write | tool in {write_file}, has filesystem:write, non-sensitive path | allow |
filesystem.escalate_delete | tool is delete_file, has filesystem:write, non-sensitive path | escalate |
filesystem.deny_delete | tool is delete_file, no filesystem:write | deny |
Database Server
| Rule | Condition | Decision |
|---|---|---|
database.block_write | write/delete tool, no database:write | deny |
database.sensitive_tables | access to PII/financial tables, no database:sensitive_read | deny |
database.read | read tool, has database:read, non-sensitive table | allow |
database.sensitive_read | read tool, has both database:read + database:sensitive_read | allow |
database.write | write tool, has database:write | allow |
database.escalate_delete | delete tool, has database:write | escalate |
Sensitive tables: employees_pii, financial_records, health_records, salaries
Slack (via web server)
| Rule | Condition | Decision |
|---|---|---|
slack.deny_delete | slack_delete_message, no slack:delete | deny |
slack.protected_channels | posting to admin/security/exec channels, no slack:protected_channels | deny |
slack.escalate_broadcast | posting to general/announcements/company-wide | escalate |
slack.read | slack_list_channels, has slack:read | allow |
slack.post | slack_post_message, has slack:post, non-broadcast, non-protected | allow |
GitHub (via web server)
| Rule | Condition | Decision |
|---|---|---|
github.escalate_merge | github_merge_pr, no github:merge | escalate |
github.read | github_list_repos, has github:read | allow |
github.write | github_create_issue, has github:write | allow |
Generic HTTP (via web server)
| Rule | Condition | Decision |
|---|---|---|
web_api.http_get | http_get, has web:read | allow |
web_api.escalate_post | http_post, has web:write | escalate |
web_api.deny_post | http_post, no web:write | deny |
RBAC Fallback (no specific server policy)
| Rule | Condition | Decision |
|---|---|---|
rbac | role permits action on resource (via data.roles) | allow |
rbac (escalate) | no rbac_allow, agent is high/critical risk tier + sensitive action | escalate |
| default | nothing matched | deny |
Writing Custom Policies
Custom policies are stored in the database and synced to OPA under policies/{tenant_id}/{policy_id}.rego.
Example: Block specific agent from all writes
package behavry.authz.custom
import rego.v1
# Override: a specific agent is read-only
decision := {
"result": "deny",
"reason": "Agent data-analyst-01 is restricted to read operations only",
"policy": "custom.agent_readonly",
} if {
input.agent.id == "a1b2c3d4-0000-0000-0000-000000000001"
input.request.action in {"write", "delete", "execute"}
}
Example: Escalate all actions for high-risk agents
package behavry.authz.custom
import rego.v1
decision := {
"result": "escalate",
"reason": "High-risk agent requires human approval for all non-read actions",
"policy": "custom.high_risk_escalate",
} if {
input.agent.risk_tier in {"high", "critical"}
input.request.action != "read"
}
Example: Time-based restriction (not yet in OPA data -- future)
package behavry.authz.custom
import rego.v1
# Deny writes outside business hours (UTC)
decision := {
"result": "deny",
"reason": "Write operations only permitted during business hours (09:00-18:00 UTC)",
"policy": "custom.business_hours",
} if {
input.request.action in {"write", "delete"}
hour := time.clock(time.now_ns())[0]
not (hour >= 9; hour < 18)
}
Policy Change Request Workflow
Policy modifications go through a structured approval workflow before taking effect. This prevents accidental or unauthorized policy changes from reaching production.
Lifecycle
- Draft a change: An administrator submits a change request against an existing policy, specifying proposed Rego content, name, description, and a summary of the change.
- Review: The change request appears in the Policies dashboard with an amber pending badge. Other administrators can review the proposed Rego diff.
- Approve or reject:
- Approve -- the proposed Rego content is applied to the policy record, synced to OPA immediately, and the policy becomes active. An audit event of type
policy_change_approvedis recorded. - Reject -- the change request is closed with reviewer notes. An audit event of type
policy_change_rejectedis recorded.
- Approve -- the proposed Rego content is applied to the policy record, synced to OPA immediately, and the policy becomes active. An audit event of type
API Endpoints
| Method | Path | Description |
|---|---|---|
POST | /api/v1/policies/{id}/change-requests | Submit a change request |
GET | /api/v1/policies/{id}/change-requests | List change requests for a policy |
POST | /api/v1/policies/{id}/change-requests/{req_id}/approve | Approve and apply |
POST | /api/v1/policies/{id}/change-requests/{req_id}/reject | Reject with notes |
GET | /api/v1/policies/change-requests/pending | Count pending requests across all policies |
Dashboard Integration
The Policies page includes a ChangeRequestsPanel below the Rego source editor. The Policies navigation item shows an amber badge with the count of pending change requests (refreshed every 30 seconds).
Red Team Policy Automation (Policy Candidates)
The Policy Generator continuously monitors the event bus for security-relevant detection findings and automatically proposes candidate Rego policies to close observed gaps. This creates a feedback loop: detect a threat pattern, generate a policy to block it, and either auto-activate or present it for human review.
Handled Event Types
The generator responds to six event types:
| Event Type | Generated Template |
|---|---|
INBOUND_INJECTION_DETECTED | injection_block -- block the specific injection pattern |
INBOUND_INJECTION_BLOCKED | injection_block -- reinforce existing block |
INJECTION_CONDITIONING_SUSPECTED | conditioning_block -- block gradual conditioning attempts |
BEHAVIORAL_DRIFT_DETECTED | drift_escalate -- escalate drifting agent actions |
REQUESTER_SESSION_CYCLING | requester_validation / rate_ceiling -- restrict session cycling patterns |
BEHAVIOR_REVERSAL | behavior_reversal -- restrict agents reversing previously blocked actions |
Seven Rego templates are available: injection_block, conditioning_block, drift_escalate, resource_restrict, rate_ceiling, requester_validation, and behavior_reversal.
Confidence Scoring
Each candidate receives a confidence score (0.0 to 1.0) computed from three factors:
| Factor | Contribution |
|---|---|
| Pattern frequency | 1 occurrence = 0.3, 3+ = 0.6, 10+ = 0.9 |
| Severity | Critical = +0.3, High = +0.2, Medium = +0.1 |
| Corroboration | 2+ distinct source event types for same pattern = +0.2 |
The final score is capped at 1.0.
Pattern Deduplication
Each candidate is assigned a normalized fingerprint signature derived from the source event type and key fields (agent ID, tool name, resource pattern, requester ID, etc.). If a candidate with the same (tenant_id, signature) already exists in proposed or auto_activated status, no duplicate is created -- instead, the frequency counter for that pattern is incremented, which may raise the confidence score.
Auto-Activation
When a tenant has auto_activate_enabled = true on their configuration, candidates whose confidence score meets or exceeds auto_activate_threshold are automatically activated: the generator creates a Policy record, pushes the Rego to OPA, and marks the candidate as auto_activated. No human review is required.
Candidates below the threshold remain in proposed status for manual review.
Manual Review
Administrators can review candidates in the Policy Suggestions dashboard:
- Inspect the generated Rego and the source detection events
- Edit the Rego inline before approving
- Test the policy with a dry-run against OPA (pushes to a temporary path and evaluates)
- Approve to create and activate the policy
- Reject with notes explaining why the candidate was not adopted
Auto-generated policies are stored under policies/autogen/ and use the behavry.authz.autogen OPA package.
API Endpoints
| Method | Path | Description |
|---|---|---|
GET | /api/v1/policy-candidates | List candidates (filterable by status) |
GET | /api/v1/policy-candidates/stats | Summary counts by status |
GET | /api/v1/policy-candidates/{id} | Get candidate details |
POST | /api/v1/policy-candidates/{id}/approve | Approve and activate |
POST | /api/v1/policy-candidates/{id}/reject | Reject with notes |
PATCH | /api/v1/policy-candidates/{id}/rego | Update candidate Rego before approval |
POST | /api/v1/policy-candidates/{id}/test | Dry-run test against OPA |
Policy Lifecycle
- Create policy via
POST /api/v1/policieswithrego_contentandstatus: "draft" - Validate manually:
opa check policies/(OPA CLI) - Activate:
PATCH /api/v1/policies/{id}with{"status": "active"} - Activation syncs the Rego to OPA via
PUT /v1/policies/{id}(REST) - Deactivate: set status to
archived-- removed from OPA
For changes to existing active policies, use the Policy Change Request Workflow to ensure review and auditability.
For auto-generated policies, the Red Team Policy Automation loop handles creation and activation automatically when confidence thresholds are met.
Policy Exceptions (Standing Allowlist)
A PolicyException bypasses OPA for specific (tool_name, action, target_pattern) tuples. Checked before OPA on every request. Use for:
- Approved recurring actions that would always escalate
- Pre-authorized workflows
Created via "Approve + Exception" in the escalation dialog or POST /api/v1/exceptions.
{
"tool_name": "delete_file",
"action": "delete",
"target_pattern": "/tmp/",
"justification": "Approved for automated temp file cleanup during nightly batch jobs",
"expires_in_hours": 720,
"extension_count": 0,
"max_extensions": 4
}
Exception Hardening
Creating exceptions via POST /api/v1/exceptions requires two mandatory fields:
justification: A text explanation of at least 10 characters describing why the exception is needed.expires_in_hours: An integer between 1 and 8760 (one year maximum) specifying the exception's lifetime.
These requirements ensure that every exception has a documented rationale and a defined expiration, preventing the accumulation of unbounded standing exceptions.
Rate Limiting and Anomaly Detection
The system monitors exception creation frequency per agent. If more than 5 exceptions are created within a one-hour window for the same agent, an alert is raised. This guards against scenarios where an attacker or misconfigured automation creates a large number of exceptions to weaken the policy posture.
Similarly, baseline approval frequency is tracked. If more than 3 baseline approvals occur for the same agent within a 24-hour window, a BASELINE_POISONING_SUSPECTED event is emitted. This detects attempts to gradually shift an agent's behavioral baseline by repeatedly triggering and approving small deviations.
Extension and Urgency
Timed exceptions can be extended up to max_extensions times (default 4) via PATCH /api/v1/exceptions/{id}/extend. Each extension adds hours to expires_at and increments extension_count. The dashboard shows escalating urgency colors as extensions accumulate (yellow, orange, red), signaling admins to make a permanent decision -- either create a permanent exception or block the action.