Policies
Supported modes: Hosted Hybrid Local Available in: Free Solo Teams (retention varies by tier -- see Feature Availability)
Policies are the rules that govern what your AI agents can and cannot do. This page covers everything: how to construct them, how they connect to your SDK calls, evaluation order, wildcards, conditions, encryption, and caching.
The Core Concept
A policy is a named collection of rules. Each rule says: "When an agent tries to do action X on resource Y, the answer is allow or deny."
{
"name": "my-policy",
"rules": [
{
"effect": "allow",
"action": "llm:generate",
"resource": "model/gpt-4"
}
]
}
When your SDK code calls cz.guard("llm", method="generate", args={"model": "gpt-5.4"}), the SDK finds this rule, sees the effect is allow, and lets the action proceed.
Action string format: Action strings in policy rules use colon-separator format (
tool:method). SDK calls passtoolandmethodas separate parameters, and they are matched astool:method. Examples:llm:generate,tool:call,data:read,api:request.
How SDK Calls Map to Policy Rules
This is the most important thing to understand. The guard() call maps the tool name and method to a policy action, and the args to policy conditions:
SDK call: Policy rule:
cz.guard( {
"llm", "action": "llm:generate",
method="generate", --> "resource": "model/gpt-5.4",
args={ --> "conditions": {
"model": "gpt-5.4", "agent_id": "agent-*"
"agent_id": "agent-001", },
}, "effect": "allow"
) }
- The tool name and method form the policy action string (e.g.,
"llm"+"generate"="llm:generate"). - The args dictionary is matched against the rule's
conditionsobject (if present). - If all fields match, the rule's
effectdetermines the outcome.
Constructing a Policy: Step by Step
Step 1: Choose Your Actions
Actions describe what the agent is trying to do. You define the naming convention. Here are common patterns:
| Action | What It Means | When to Use |
|---|---|---|
llm:generate | Call an LLM for text generation | Before any chat.completions.create() or messages.create() call |
llm:embed | Generate embeddings | Before any embeddings.create() call |
tool:call | Invoke a tool or function | Before executing a tool the LLM requested |
mcp.tool:call | Invoke an MCP tool | Before calling a tool via MCP protocol |
mcp.resource:read | Read an MCP resource | Before reading data via MCP |
data:read | Read from a data source | Before querying a database or vector store |
data:write | Write to a data source | Before inserting or updating data |
file:read | Read a file | Before accessing a file on disk |
file:write | Write a file | Before writing or modifying a file |
api:request | Make an outbound HTTP request | Before calling an external API |
* | Any action | Catch-all rule |
You can invent your own action names too. They are just strings. The SDK and policy rules use the same strings. That is how they connect.
Step 2: Choose Your Resources
Resources describe the target of the action. You define the naming convention:
| Resource Pattern | What It Targets | Example SDK Call |
|---|---|---|
model/gpt-5.4 | A specific LLM model | guard("llm", method="generate", context={"resource": "model/gpt-5.4"}) |
model/claude-* | All Claude models (wildcard) | guard("llm", method="generate", context={"resource": "model/claude-sonnet-4-6"}) |
tool/search_web | A specific tool | guard("tool", method="call", context={"resource": "tool/search_web"}) |
mcp://filesystem/read_file | A specific MCP tool | guard("mcp.tool", method="call", context={"resource": "mcp://filesystem/read_file"}) |
mcp://filesystem/* | All tools on an MCP server | Matches any tool on the filesystem server |
vectorstore/documents | A data collection | guard("data", method="read", context={"resource": "vectorstore/documents"}) |
https://api.example.com/* | An API endpoint | guard("api", method="request", context={"resource": "https://api.example.com/v1/users"}) |
* | Any resource | Catch-all |
Step 3: Set the Effect
Each rule has an effect: either allow or deny.
[
{ "effect": "allow", "action": "llm:generate", "resource": "model/gpt-5.4" },
{ "effect": "deny", "action": "llm:generate", "resource": "model/gpt-4*" }
]
Step 4: Add Conditions (Optional)
Conditions let you restrict a rule based on runtime values passed from the SDK. They are implemented in the local policy enforcer (Python, Node, and Go), so they work in both hosted and local-only modes.
{
"effect": "allow",
"action": "llm:generate",
"resource": "model/gpt-4",
"conditions": {
"agent_id": "support-*",
"environment": "production"
}
}
See the dedicated Conditions section below for the full matching semantics.
Validation
When you publish or save a policy version, the platform validates every
actions[*] entry in every rule against the canonical action set and
the alias table (see Canonical tool names).
A rule that targets an unknown action (typo, made-up name) is rejected
with a 422 validation_failed response carrying a did_you_mean
suggestion list. This catches the silent "rule lands but never fires"
class of bug at authoring time rather than after the rule has been
shipped to production.
Example response when a rule targets database:queryy (typo):
{
"error": "validation_failed",
"unknown_actions": ["database:queryy"],
"suggestions": {
"database:queryy": ["database:query (legacy)"]
}
}
The dashboard rule editor renders the offending rule with a red
border and a one-click "use database:query" button per suggestion.
Suggestions tagged (legacy) come from the alias shim for pre-canonical
rules; suggestions without a tag are the modern canonical class
(prefer those for new rules).
The SDK runs the same validator as a warning at policy-load time so local-policy-mode users (no platform backend) still see the typo. The SDK warning is non-blocking -- the policy still loads -- so customers using a custom tool the SDK does not yet know about can keep going while still seeing the did-you-mean for genuine typos.
The validator's known-action set is the union of canonical SDK
extractor tools, host-tool aliases (e.g. Read resolves to
file_read), the four canonical SQL semantic classes
(database:read|write|admin|exec), every legacy SQL alias
(database:query, database:SELECT, database:DROP, ...), and
wildcards (*, tool:*, *:method). Updating the SDK alias table
automatically widens what the validator accepts.
Rule Fields Reference
| Field | Type | Required | Description |
|---|---|---|---|
effect | "allow" or "deny" | Yes | Whether the rule permits or blocks the action |
action | string | Yes | The action to match. Supports wildcards (*) |
resource | string | Yes | The resource to match. Supports wildcards (*) |
conditions | object | No | Key-value pairs that must all match the request context. Values support glob patterns |
clients | list of strings | No | Restrict the rule to specific AI clients (cursor, claude-code, gemini-cli, codex-cli, windsurf, python-sdk, etc.). Empty/absent = matches every client. Supports glob patterns. Added in #175. |
projects | list of strings | No | Restrict the rule to specific project IDs. Empty/absent = matches every project. Supports glob patterns. Added in #175. |
Selector semantics (clients / projects)
A rule with a non-empty clients list is skipped unless the request's
detected client name matches one of the entries. Same for projects. Both
selectors must positively pick the request: an empty client_name does NOT
match clients: ["cursor"]. First-match-wins still applies — there is no
implicit "most specific wins" re-ordering. Express priority by authoring
the more specific rule before the more general one:
rules:
- allow: delete_* # cursor-only override fires first
clients: ['cursor']
- deny: delete_* # global default applies to everything else
Glob syntax note: clients / projects glob support matches the rule
for action / resource — *, prefix*, *suffix in Rust + Go, plus
? and [seq] in Python (via fnmatch.fnmatchcase). This is a
pre-existing cross-SDK behavior class that also applies to actions and
resources; we recommend sticking to the lowest-common-denominator syntax
(exact match, prefix*, *suffix) for portability.
Wildcards
Both action and resource fields support glob-style wildcards:
[
{ "action": "llm:*", "resource": "*" },
{ "action": "mcp.tool:call", "resource": "mcp://filesystem/*" },
{ "action": "*", "resource": "*" }
]
| Pattern | Matches | Does Not Match |
|---|---|---|
model/gpt-5.4 | model/gpt-5.4 only | model/gpt-5.4-mini |
model/gpt-5.4* | model/gpt-5.4, model/gpt-5.4-mini | model/gpt-4-turbo |
model/* | Any model | tool/search_web |
mcp://filesystem/* | mcp://filesystem/read_file, mcp://filesystem/write_file | mcp://github/create_issue |
* | Everything | (matches all) |
Conditions
Conditions refine a rule with key-value glob patterns that must all match at runtime. They are evaluated by the local enforcer in every SDK (Python, Node, Go), both in hosted and local-only modes — there is no server round trip.
What each condition matches against
When a rule has conditions: { key: pattern, ... }, the enforcer builds a
merged view of the guard call and tests each key against that view.
The merge rules are:
- Start with the call's
argsdict. - Layer
contexton top (context wins on key collisions). - If
context["tags"]is a mapping, flatten it into the top level at the same precedence ascontext. - Each condition value is a glob (
*wildcard). If the value atkeymatches the glob, the condition passes. All conditions must pass.
Practical consequence: you can drive a condition from any of args,
top-level context, or nested context["tags"]. Pick whichever is most
natural for the caller.
Example: provider tag (simplified wrapper pattern)
- effect: allow
action: 'llm:generate'
conditions:
provider: 'openai'
This rule matches if any of these calls is made:
# via top-level context (direct guard)
cz.guard("llm", method="generate", context={"provider": "openai"})
# via nested tags (simplified wrapper populates tags)
cz.guard("llm", method="generate", context={"tags": {"provider": "openai"}})
# via args (some integrations put it there)
cz.guard("llm", method="generate", args={"provider": "openai"})
Example: agent + environment
{
"effect": "allow",
"action": "llm:generate",
"conditions": {
"agent_id": "support-*",
"environment": "production"
}
}
cz.guard(
"llm",
method="generate",
context={
"agent_id": "support-agent-1", # matches "support-*"
"environment": "production", # matches "production"
},
)
Example: nested tags
When a wrapper classifies a call with tags (provider, model family, cost tier), conditions can match those tags as if they were top-level:
{ "effect": "deny", "action": "llm:generate", "conditions": { "cost_tier": "premium" } }
cz.guard(
"llm",
method="generate",
context={"tags": {"cost_tier": "premium", "provider": "openai"}},
)
# Denied: conditions.cost_tier matches tags.cost_tier after flattening.
Policy Evaluation Order
When multiple rules could match the same guard() call, Control Zero applies this precedence:
Three rules:
- Explicit deny always wins. If any deny rule matches, the action is blocked, even if an allow rule also matches.
- More specific rules take precedence over broader ones. A rule for
model/gpt-4beats a rule formodel/*. - No match = deny. If no rule matches the action and resource, the action is denied. This is the secure-by-default posture.
Example: Evaluation in Practice
Given this policy:
{
"rules": [
{ "effect": "allow", "action": "llm:generate", "resource": "model/*" },
{ "effect": "deny", "action": "llm:generate", "resource": "model/gpt-4*" }
]
}
| SDK Call | Rule 1 Match? | Rule 2 Match? | Result |
|---|---|---|---|
guard("llm:generate", "model/gpt-4") | Yes (allow) | No | ALLOWED |
guard("llm:generate", "model/gpt-3.5-turbo") | Yes (allow) | No | ALLOWED |
guard("llm:generate", "model/gpt-4-turbo") | Yes (allow) | Yes (deny) | DENIED (deny wins) |
guard("tool:call", "tool/search") | No | No | DENIED (no match = deny) |
Complete Policy Examples
Example 1: Model Governance
Allow specific models, deny everything else:
{
"name": "model-governance",
"description": "Only approved models can be used",
"rules": [
{
"effect": "allow",
"action": "llm:generate",
"resource": "model/gpt-5.4"
},
{
"effect": "allow",
"action": "llm:generate",
"resource": "model/claude-sonnet-4-6"
},
{
"effect": "deny",
"action": "llm:generate",
"resource": "model/*"
}
]
}
The last rule acts as a catch-all: any model not explicitly allowed is denied.
In your code:
cz.guard("llm", method="generate", context={"resource": "model/gpt-5.4"}) # ALLOWED
cz.guard("llm", method="generate", context={"resource": "model/claude-sonnet-4-6"}) # ALLOWED
cz.guard("llm", method="generate", context={"resource": "model/gpt-4o"}) # DENIED (catch-all)
Example 2: MCP Tool Control
Control which MCP tools an agent can invoke:
{
"name": "mcp-tool-control",
"description": "Restrict MCP tool access per agent",
"rules": [
{
"effect": "allow",
"action": "mcp.tool:call",
"resource": "mcp://filesystem/read_file",
"conditions": { "agent_id": "analyst-*" }
},
{
"effect": "deny",
"action": "mcp.tool:call",
"resource": "mcp://filesystem/write_file"
},
{
"effect": "allow",
"action": "mcp.tool:call",
"resource": "mcp://github/*",
"conditions": { "agent_id": "dev-agent" }
},
{
"effect": "deny",
"action": "mcp.tool:call",
"resource": "mcp://*"
}
]
}
In your code:
# Analyst agent reading a file: ALLOWED (rule 1 matches)
cz.guard(
"mcp.tool",
method="call",
context={"resource": "mcp://filesystem/read_file", "agent_id": "analyst-42"},
)
# Any agent writing a file: DENIED (rule 2 matches)
cz.guard(
"mcp.tool",
method="call",
context={"resource": "mcp://filesystem/write_file", "agent_id": "analyst-42"},
)
# Dev agent using GitHub: ALLOWED (rule 3 matches)
cz.guard(
"mcp.tool",
method="call",
context={"resource": "mcp://github/create_issue", "agent_id": "dev-agent"},
)
# Unknown MCP tool: DENIED (rule 4 catch-all)
cz.guard(
"mcp.tool",
method="call",
context={"resource": "mcp://slack/send_message", "agent_id": "analyst-42"},
)
Example 3: Multi-Layer Production Policy
A policy combining model governance, tool control, data access, and API restrictions:
{
"name": "production-agent-policy",
"description": "Full governance for production AI agents",
"rules": [
{
"effect": "allow",
"action": "llm:generate",
"resource": "model/gpt-4",
"conditions": { "environment": "production" }
},
{
"effect": "allow",
"action": "tool:call",
"resource": "tool/search_web"
},
{
"effect": "deny",
"action": "tool:call",
"resource": "tool/execute_code"
},
{
"effect": "allow",
"action": "data:read",
"resource": "vectorstore/public-docs"
},
{
"effect": "deny",
"action": "data:read",
"resource": "vectorstore/internal-*"
},
{
"effect": "allow",
"action": "api:request",
"resource": "https://api.internal.example.com/*"
},
{
"effect": "deny",
"action": "api:request",
"resource": "https://*.external.example.com/*"
},
{
"effect": "deny",
"action": "*",
"resource": "*"
}
]
}
The last rule is a global catch-all: anything not explicitly allowed is denied.
Policy Bundles
Policies are not sent to SDKs individually. Instead, all active policies for a project are compiled into a single policy bundle.
How Bundles Work
When you publish policies in the dashboard, Control Zero delivers them to your SDK as a single bundle. New policies become active in your agent within about a minute, or immediately with a manual refresh.
Bundle Security
Bundles are delivered securely and tamper-protected end-to-end. If a bundle fails verification for any reason, the SDK ignores it and keeps enforcing the last known good policy, so you never silently lose enforcement.
Local Caching
The SDK caches the current bundle on disk. This provides:
- Offline enforcement: If the SDK cannot reach the server, it enforces the last known good policy.
- Zero-latency evaluation: Every
guard()call evaluates against local memory. No network round-trip. - Resilience: Network outages or server maintenance do not interrupt policy enforcement.
Default cache location:
- Python:
~/.controlzero/cache/ - Go:
~/.controlzero/cache/ - Node.js:
~/.controlzero/cache/
Refreshing Policies
The SDK polls for new bundles every 60 seconds by default. You can also force a refresh:
# Python: policies refresh automatically; close the client when done
client.close()
// Go
err := client.RefreshPolicies(ctx)
// Node.js
await cz.refreshPolicies();
Free: 7 days. Solo: 90 days. Teams: 365 days. Longer retention and compliance exports are available in Solo and Teams. View pricing
Governing MCP tool calls
Model Context Protocol (MCP) tools give AI agents broad capabilities: file system access, database queries, shell execution, API calls. Control Zero governs each MCP tool call as an (action, resource) pair where the action is mcp.tool:call and the resource follows the mcp://{server}/{tool} convention.
This section is for MCP-native clients (Claude Code, Cline, Cursor) and custom MCP gateways that want to enforce policies on every tool invocation.
Common patterns
Read-only agent -- allow reads, deny writes and shell:
{
"name": "read-only-agent",
"rules": [
{ "effect": "allow", "action": "mcp.tool:call", "resource": "mcp://filesystem/read_file" },
{ "effect": "allow", "action": "mcp.tool:call", "resource": "mcp://filesystem/list_directory" },
{ "effect": "deny", "action": "mcp.tool:call", "resource": "mcp://filesystem/write_file" },
{ "effect": "deny", "action": "mcp.tool:call", "resource": "mcp://filesystem/delete_file" },
{ "effect": "deny", "action": "mcp.tool:call", "resource": "mcp://shell/execute" }
]
}
Database reader -- allow SELECT, deny writes:
{
"name": "db-reader-agent",
"rules": [
{ "effect": "allow", "action": "mcp.tool:call", "resource": "mcp://database/read_query" },
{ "effect": "deny", "action": "mcp.tool:call", "resource": "mcp://database/write_query" },
{ "effect": "deny", "action": "mcp.tool:call", "resource": "mcp://database/execute_query" }
]
}
API-only agent -- allow outbound HTTP, deny local access:
{
"name": "api-only-agent",
"rules": [
{ "effect": "allow", "action": "mcp.tool:call", "resource": "mcp://http/request" },
{ "effect": "deny", "action": "mcp.tool:call", "resource": "mcp://filesystem/*" },
{ "effect": "deny", "action": "mcp.tool:call", "resource": "mcp://shell/*" },
{ "effect": "deny", "action": "mcp.tool:call", "resource": "mcp://database/*" }
]
}
MCP policy gateway pattern
For MCP-native clients, wrap tool invocations in a small gateway that calls guard() before forwarding to the real MCP server:
from controlzero import Client
from typing import Any
cz = Client(api_key="cz_live_your_api_key_here")
class PolicyGateway:
"""MCP gateway that enforces Control Zero policies on tool calls."""
def __init__(self, agent_id: str):
self.agent_id = agent_id
def call_tool(self, server: str, tool: str, arguments: dict) -> Any:
cz.guard(
"mcp.tool",
method="call",
context={"resource": f"mcp://{server}/{tool}", "agent_id": self.agent_id},
args=arguments,
)
return self._forward_to_server(server, tool, arguments)
def check_tool(self, server: str, tool: str) -> bool:
"""Check if a tool call would be allowed (without enforcing)."""
decision = cz.guard(
"mcp.tool",
method="call",
context={"resource": f"mcp://{server}/{tool}", "agent_id": self.agent_id},
)
return decision.effect == "allow"
def _forward_to_server(self, server, tool, arguments):
# Implementation depends on your MCP client library.
pass
Filtering the tool list at presentation time
Before presenting tools to an agent, filter the catalog by policy so the model never sees tools it is not allowed to call:
available_tools = [
("filesystem", "read_file"),
("filesystem", "write_file"),
("database", "read_query"),
("database", "write_query"),
("shell", "execute"),
("http", "request"),
]
allowed_tools = []
for server, tool in available_tools:
decision = cz.guard(
"mcp.tool",
method="call",
context={"resource": f"mcp://{server}/{tool}", "agent_id": "my-agent"},
)
if decision.effect == "allow":
allowed_tools.append((server, tool))
This keeps the model from even attempting denied tools, reducing wasted tokens and noisy audit events.
Next Steps
- Quick Start: Build a working agent with policy enforcement.
- Projects: Organize agents into projects with separate policies.
- Integrations: Auto-enforce on OpenAI, Anthropic, LangChain, and more.