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: each field is scanned up to its first 256 KB (and about 1 MB per request across all fields), so a pasted script with an embedded key is still caught. Above that window, PII patterns are skipped as too noisy and only secret patterns run. A field longer than 256 KB is not skipped and not blocked: redact and tokenize mask exactly the window detection scanned and forward the rest unchanged, so a long coding-assistant conversation with a key in it is masked and sent rather than denied.
If a redact or tokenize verdict still can't fully mask what detection found, the request is blocked by default (fail closed). A Security Profile's if masking incomplete setting (shown when the profile redacts or tokenizes PII or secrets) can switch that to allow + log: the request is forwarded partially masked and the decision is always logged with the unmasked fields named. In YAML this is on_unmaskable: block | allow_logged, on a profile or on a rule.
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; run new rules as log first and check Live Traffic to confirm you haven't blocked something legitimate before you enforce.
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 Policy Rules page flags this for you — a rule below a catch-all that can never fire shows a policy warning.
Two legs: request and response
A call has two legs: the request the agent sends out and the response that comes back. The engine walks the rule list once per leg, independently. match.direction (the Direction field on the editor's Content tab) picks the leg a rule runs on:
direction | Policy Rules label | Runs on |
|---|---|---|
request | Outbound | the outgoing request only |
response | Inbound | the reply only (tool results, model output) |
| (unset) | Both | both legs |
This is where "first match wins" can surprise you. A rule only shadows the rules below it on the legs it covers:
- name: allow_partner_api
match: { direction: request, destination: { host: api.partner.example } }
action: allow
- name: block_everything_else
match: { destination: { host: "*" } } # no direction → both legs
action: denyThe allow wins on the request, but it doesn't run on the reply, so block_everything_else still matches the response and blocks it. Access decisions (who / where) belong on the request leg: give the deny direction: request. The Policy Rules page warns about exactly this shape — a deny that matches only on destination or identity with no direction.
Three exceptions to "unset means both":
- A
require_approvalrule with no direction runs on the request leg only. Otherwise an approved request would be released and then its reply denied. To hold replies, setdirection: responseexplicitly. - Content rules (PII, secrets, injection, …) with no direction genuinely run on both legs; that's how response-side DLP works.
- A Security Profile's data-protection checks (secrets, PII, bulk egress) run on the request leg only, even when the rule carrying the profile matches both legs. The intent is "stop sensitive data leaving" — a reply is full of strings that merely look like secrets (random base64 on an ordinary web page passes the entropy checks), and masking a reply body that can't be fully rewritten would block pages you never sent data to. Set
scan_responses: trueon the profile for deliberate response-side DLP (e.g. "the model echoed a secret back"). The profile's injection and dangerous-action checks always run on both legs — replies are exactly where injected instructions arrive.
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; collectors pick up the new order on their next policy sync — 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).