Writing Policies
Policies are YAML rules that decide what each agent can do. First match wins. Fail-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 — no daemon restart required. 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:
This means agent: "claude" rules are checked before agent: "*" rules, regardless of file order.
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
This allows transfers under 100 automatically, requires 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
It matches text, not meaning. Three consequences worth stating plainly:
- 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) -rf, a script file — all pass. This raises the floor against accidents and careless commands. It is not a sandbox, and treating it as one is the mistake it invites.
Values that are not strings are matched on their rendered form, so a tool taking
{"args": ["push", "--force"]} is searched as [push --force]. That is
deliberate: 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
- 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