Writing Policies
Policies are YAML rules that decide what each agent can do. The first matching rule wins. Evaluation fails closed: if no rule matches, the tool call is denied.
Basic structure
policies:
- name: my-policy
agent: "claude" # which agent (glob pattern)
rules:
- tools: ["filesystem.read_*"]
action: allow
- tools: ["filesystem.write_*"]
action: human_approval
- tools: ["*"]
action: deny
Actions
| Action | What happens |
|---|---|
allow |
Tool call proceeds immediately |
deny |
Tool call rejected, 403 returned |
human_approval |
Tool call queued, waits for human/supervisor |
First match wins
Rules are evaluated top to bottom. The first rule whose tools pattern matches is applied. Put specific rules before general ones:
rules:
# Specific: deny destructive gmail operations
- tools: ["gmail.delete_*", "gmail.move_to_trash"]
action: deny
# Medium: require approval for sends
- tools: ["gmail.send_email"]
action: human_approval
# General: allow reads
- tools: ["gmail.read_*", "gmail.list_*"]
action: allow
# Catch-all
- tools: ["*"]
action: deny
Glob patterns
Both agent and tools fields support glob patterns:
| Pattern | Matches |
|---|---|
"claude" |
Exact match |
"*" |
Any agent or tool |
"worker-*" |
worker-1, worker-docs, etc. |
"gmail-*.send_email" |
gmail-ktcrisis.send_email, gmail-perso.send_email |
"filesystem.read_*" |
filesystem.read_file, filesystem.read_multiple_files |
Per-agent policy files
Instead of putting all policies inline, use a directory:
policies/
├── claude.yaml # rules for agent "claude"
├── worker.yaml # rules for agent "worker-*"
└── default.yaml # catch-all rules
Each file is a single policy:
# policies/claude.yaml
name: claude
agent: "claude"
rules:
- tools: ["filesystem.read_*"]
action: allow
- tools: ["filesystem.write_*"]
action: human_approval
Files are loaded alphabetically after any inline policies:. Duplicate names produce an error.
Hot-reload
Policies are reloaded automatically when files change, without a daemon restart. The daemon watches both config.yaml and the policy_dir/ directory using filesystem notifications.
What gets reloaded:
- Inline
policies:inconfig.yaml - All
*.yamlfiles inpolicy_dir/ - Rate limits defined in policies
What does NOT get reloaded (requires restart):
- MCP servers, CLI tools, OpenAPI specs
- Port, storage path, supervisor config
Safety guarantees:
- Changes are debounced (200ms) to handle editors that write multiple events per save
- New policies are fully parsed and validated before swapping. Invalid YAML is logged and rejected
- The mesh never crashes on a bad reload; current policies stay active
Example: add a policy at runtime
# Takes effect in <1s
cat > policies/temp-agent.yaml << 'EOF'
name: temp-agent
agent: "temp"
rules:
- tools: ["weather.*"]
action: allow
EOF
# Verify
curl -s -X POST localhost:9090/decide \
-d '{"agent":"temp","tool":"weather.weather_forecast"}' | jq .action
# → "allow"
# Remove — reverts immediately
rm policies/temp-agent.yaml
Policy specificity
When multiple policies match an agent, more specific agent globs are evaluated first:
Hence agent: "claude" rules are checked before agent: "*" rules, regardless of file order.
Inspecting the active policies
GET /policies returns the policies currently in force, in evaluation order (after the specificity sort and any hot-reload). It is a control-plane endpoint: loopback, or Authorization: Bearer <admin_token> (see Control Plane Auth).
curl -s localhost:9090/policies | jq '.[] | {name, agent, source_file}'
# → {"name": "claude", "agent": "claude", "source_file": "claude.local.yaml"}
# → {"name": "default", "agent": "*", "source_file": "default.yaml"}
source_file is the file the policy was read from, relative to policy_dir, and is absent for policies declared inline in config.yaml. The loader sets this field, which cannot be set from YAML. The field exists because a policy's name and its file name need not match (claude.local.yaml can declare name: claude), so a tool that wants to edit a rule can find its file without guessing.
Conditions
Rules can include conditions on request parameters:
rules:
- tools: ["payment.transfer"]
action: allow
condition:
field: "amount"
operator: "<"
value: 100
- tools: ["payment.transfer"]
action: human_approval
These rules allow transfers under 100 automatically and require approval for larger amounts.
The field path
field starts at the tool's arguments, not above them. For a call carrying
{"amount": 100} the field is amount. For a nested {"order": {"total": 100}}
it is order.total.
Writing params.amount looks natural and is wrong: there is no params key
inside the arguments, so the path resolves to nothing and the condition is
false. A rule whose condition is false is skipped, which means an allow
never allows and, worse, a deny never denies. Nothing is logged as an error.
Check a new condition against POST /decide before trusting it.
Supported operators
| Operator | Operand | Example |
|---|---|---|
< <= > >= |
number | amount < 100 |
== != |
number or string | env == "prod" |
contains |
string or list | command contains "rm -rf" |
not_contains |
string or list | command not_contains ["curl", "wget"] |
starts_with |
string or list | file_path starts_with "/home/fluxart" |
not_starts_with |
string or list | file_path not_starts_with ["/etc", "/usr"] |
A list operand means any of these for the positive forms, and therefore none of these for the negated ones:
# Deny the shell call outright when it mentions any of these
- tools: ["Bash"]
action: deny
condition:
field: "command"
operator: "contains"
value: ["rm -rf", "mkfs", "| sh", "dd if="]
- tools: ["Bash"]
action: allow
# Writes stay inside the work tree
- tools: ["Write", "Edit"]
action: deny
condition:
field: "file_path"
operator: "starts_with"
value: ["/etc", "/usr", "/boot"]
Order matters: first match wins, so the guard goes above the permissive rule.
What string matching does not do
String matching compares text, not meaning, with the following consequences:
- Case-sensitive. A rule denying
rm -rfdoes not stopRM -RF. - No shell parsing. A needle written
curl | shdoes not catchcurl https://x | sh, because nothing here understands a pipeline. Write the fragment that will actually appear, such as| sh. - Evadable by anyone trying.
rm -r -f,$(echo rm) -rfand a script file all pass. String matching raises the floor against accidents and careless commands. However, it is not a sandbox, and the likely mistake is to treat it as one.
Values that are not strings are matched on their rendered form, so a tool taking
{"args": ["push", "--force"]} is searched as [push --force]. This behaviour is
deliberate, because the alternative would be to silently ignore every tool that takes an
argument list.
Rate limiting
Per-policy rate limits protect against runaway loops:
policies:
- name: claude
agent: "claude"
rate_limit:
max_per_minute: 60 # sliding window
max_total: 1000 # lifetime of process
rules:
- tools: ["*"]
action: allow
Loop detection is automatic: same tool + same params > 3 times in 10 seconds triggers a block.
Agent identity
Agents identify themselves differently depending on the transport:
| Transport | Identity source |
|---|---|
| MCP stdio | --mcp-agent flag |
| HTTP | Authorization: Bearer agent:<id> header |
| MCP Streamable HTTP | Authorization: Bearer agent:<id> header |
If no identity is provided, the agent is anonymous (HTTP) or uses the configured default (MCP).
Example: real-world config
policies:
- name: claude
agent: "claude"
rate_limit:
max_per_minute: 120
rules:
# Read anything
- tools: ["filesystem.read_*", "filesystem.list_*", "filesystem.search_*"]
action: allow
# Write with approval
- tools: ["filesystem.write_file", "filesystem.edit_file"]
action: human_approval
# No destructive ops
- tools: ["filesystem.move_file"]
action: deny
# Gmail: read yes, send with approval, delete never
- tools: ["gmail.delete_*", "gmail.batch_*"]
action: deny
- tools: ["gmail.read_*", "gmail.list_*"]
action: allow
- tools: ["gmail.send_email", "gmail.draft_email"]
action: human_approval
# Local LLM: always allowed
- tools: ["ollama.*"]
action: allow
# Deny everything else
- tools: ["*"]
action: deny
- name: default
agent: "*"
rules:
- tools: ["*"]
action: deny
Next steps
- Tool Classification: a draft policy from
discover, per-agent decisions, changing one tool's action - Approval Flow: what happens when a tool call hits
human_approval - CLI Tools: governing git, docker, terraform as tools
- Memory Integration: auto-approve from past decisions