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.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. (Rules can be disabled — a disabled rule is skipped in the walk.)
How match works
A match block is a conjunction — every listed condition must be true. An empty match: {} matches everything (useful for a bottom-of-list catch-all).
| Field | Type | True when |
|---|---|---|
agent | str or [str] | The request's agent equals the string, is in the list, or matches a * glob. |
action | tool_call / http_request / llm_prompt | The action kind matches exactly (or *). |
destination | {host / tool / model: str-or-glob} | Each listed key is present on the request and matches. *.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 PII scanner flagged the body. |
contains_secret | bool | The secrets scanner flagged the body. |
There are more predicates than these — user / user_groups, workload / workload_labels, daily_cost_over, calls_per_minute_over, time_window, source_ip, destination_is_ai_provider / destination_is_unknown_ai, and the MCP fields. See Managing policy for the full set. Conditions you don't list are not checked.
What destination matching means
destination is a dict, matched per key — each key you list must be present on the request and match its value (string, list, or * glob). Keys you don't list aren't checked.
destination: { host: "*.openai.com" }matches an HTTP/LLM request toapi.openai.com— but not atool_call, which carries nohost.destination: { tool: "shell.exec" }matches a tool call toshell.execregardless of host.
The Live Traffic view flattens the destination to a single display string for readability, but the engine matches on the structured keys, not the flattened string.
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 engine's built-in scanners (agentfw/matchers.py) running over request.args during evaluation:
- PII — SSN (reserved ranges filtered), credit card (Luhn-validated), email, US phone.
- Secret — AWS access key, AWS secret key, GitHub token, OpenAI key, Slack token, Bearer token, private-key blocks.
Scan-size note: secret patterns scan generously so a pasted script with an embedded key is still caught; very large PII fields may be sampled rather than scanned in full, since free-text blobs that size are noise-prone.
A redact action gated on contains_pii/contains_secret masks every sensitive leaf it finds — both PII and secret fields — not just the family that triggered the rule. So a payload with an SSN in one field and an AWS key in another has both masked, regardless of which rule fired.
Default action
Every policy has a default_action: allow | deny | log at the top of policies.yaml. It applies when no rule matched.
The shipping default for new tenants is default_action: allow with explicit rules that redact secrets/PII, hold sensitive writes for approval, and log AI chat — visibility-first, so nothing legitimate breaks on day one while the risky patterns are still caught. If you prefer default-deny ("know exactly what's allowed, reject everything else"), set default_action: deny and add explicit allow rules for the traffic you expect; use the Simulator to confirm you haven't blocked something legitimate before you ship it.
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. Reorder them on the Policy Rules page; the engine re-reads the new order on the next request — no restart.
What the engine returns
engine.evaluate(request) returns a Decision:
@dataclass
class Decision:
action: str # "allow" | "deny" | "redact" | "require_approval" | "log"
reason: str # the rule's reason, or a system-generated one
matched_rule: str # the rule's name, or "<default>" if no rule matched
redacted_args: dict | None # for action=="redact", the masked args to forward
matched_paths: list[str] # the field paths that triggered the matchThat's what the collector 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, and Mid-session policy changes for when an edit takes effect on conversations already in flight (and why a new rule can match content from hours ago).