Policy semantics
The policy engine is intentionally boring. It walks a list of rules top-to-bottom and applies the first one that matches. If nothing matches, the policy's default_action is used. That's the entire algorithm.
This document is for the moments when "boring" isn't enough — when you need to know exactly how arg_regex interacts with contains_pii, what happens when two rules could both match, or how the engine treats a missing field.
The walk
For each incoming request, the engine evaluates rules in the order they appear in policies.yaml:
for rule in policy.rules:
if rule.enabled and rule.match.matches(request):
return rule.apply(request) # first-match-wins
return policy.default_action(request)No backtracking, no scoring, no "best match". If rule 1 matches, rule 2 is never consulted, even if rule 2 is "more specific". Order is the priority.
How match works
A match block is a conjunction — every listed condition must be true. An empty match: {} matches everything (useful for the bottom-of-list default-action rule).
| Field | Type | True when |
|---|---|---|
agent | str or [str] | The request's agent equals the string, or is in the list. |
action | tool_call / http_request / llm_prompt | The action kind matches exactly. |
destination | glob pattern | The host / tool / model matches the glob. *.example.com matches api.example.com and chat.example.com, but not example.com. |
arg_regex | {field: regex} | Each listed field in request.args matches its regex (Python re.search, partial match). |
contains_pii | bool | The collector's PII scanner flagged the body. |
contains_secret | bool | The secrets scanner flagged the body. |
Conditions you don't list are not checked. A rule with just match: { action: tool_call } matches any tool call, regardless of destination or agent.
What "matches" means for nested fields
destination is normalised by the engine to a single string before glob-matching:
destination.toolif set → use that ("shell.exec", "db.query").- else
destination.host("api.openai.com"). - else
destination.model("claude-sonnet-4-6").
So destination: "api.openai.com" in a rule will not match a tool_call event — there's no host on those.
This is also why the UI has the destStr() helper: it does the same flattening so what you see in Live Traffic matches what the engine sees.
arg_regex — partial vs anchored
arg_regex uses re.search, not re.match. So:
arg_regex: { cmd: "rm\\s+-rf" }matches any cmd containing rm -rf, regardless of what's around it (echo hi && rm -rf /tmp, sudo rm -rf foo). To anchor, use ^…$:
arg_regex: { method: "^(POST|PUT|DELETE)$" } # exact matchMost rule authors get this wrong the first time — the rule fires more than they expected. If a rule is matching things you didn't mean to, anchor it.
PII and secret scanners
contains_pii and contains_secret are not regexes you control. They're the collector's built-in scanners running over request.args:
- PII — SSN, credit card, US/EU phone number, common email patterns, US street address heuristic, IBAN. Configurable per tenant; defaults are conservative.
- Secret — AWS access key / secret, Slack tokens, GitHub PATs, generic high-entropy tokens.
A redact action with contains_pii: true doesn't find the PII — it acts on the fact that it was found. The scanner has already run by the time the engine evaluates the rule. So the only place "what counts as PII" is decided is in the collector's classifier (agentfw/classification.py), not in the policy.
Default action
Every policy has a default_action: allow | deny | log at the top of policies.yaml. It applies when no rule matched.
The conventional default is deny — security-product hygiene says you should know exactly what's allowed and reject everything else. The shipping default for new tenants follows this; the last rule in their policies.yaml is an explicit deny_unknown_host so unmatched HTTP requests don't quietly fall through.
If you set default_action: allow, write a small rule explaining why. Future-you will thank present-you when you're debugging a deploy.
Ordering matters — pathological example
- name: deny_external_writes
match: { action: http_request, arg_regex: { method: "POST|PUT|DELETE" } }
action: deny
- name: approve_external_writes
match: { action: http_request, arg_regex: { method: "POST|PUT|DELETE" } }
action: require_approvalThe require_approval rule never fires — the deny rule above it always wins. Reorder to make require_approval come first.
The Simulator page can tell you this before you save: it replays the last 24h of traffic through your draft policy and shows which rules actually fire. If a rule has zero matches in the simulation, it's almost certainly being shadowed by an earlier one.
Order is just a list — reorder freely
There's no rule-id-based dependency tree. Rules are just an ordered list. To reorder, drag them on the Policy Rules page or POST /v1/policy/rules/reorder with the new name order. The engine re-reads the new order on the next request — no restart.
What the engine returns
engine.evaluate(request) returns a Decision object:
@dataclass
class Decision:
action: str # "allow" | "deny" | "redact" | "require_approval" | "log"
matched_rule: str # the rule's `name`, or "<default>" if no rule matched
reason: str # the rule's `reason`, or a system-generated one
redact_fields: list[str] # for action=="redact", the field paths to mask
duration_ms: float # wall-clock evaluation timeThat's what the addon then ships to the cloud (with the request metadata) as a single event.
→ See Decisions & verdicts for what each action actually does to the request.