Skip to content

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).

FieldTypeTrue when
agentstr or [str]The request's agent equals the string, or is in the list.
actiontool_call / http_request / llm_promptThe action kind matches exactly.
destinationglob patternThe 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_piiboolThe collector's PII scanner flagged the body.
contains_secretboolThe 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.tool if 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:

yaml
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 ^…$:

yaml
arg_regex: { method: "^(POST|PUT|DELETE)$" }   # exact match

Most 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

yaml
- 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_approval

The 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:

python
@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 time

That'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.

Documentation for kilasec — the AI Agent Firewall.