> For the complete documentation index, see [llms.txt](https://docs.kyvvu.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.kyvvu.com/policy-authoring/rules-reference.md).

# Built-in Rules Reference

**What you'll learn:** All 27 built-in rule functions, grouped by category, with their parameters and applicable enforcement points.

***

The engine ships with 27 built-in rules grouped into six categories. Each rule is a pure function: `rule(data, params, context) -> bool`. Returns `True` to pass, `False` to violate.

## Field rules

Applicable to `agent_registration` and `step_execution`.

| Rule                  | What it checks                                                                                     | Parameters                        |
| --------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------- |
| `field_not_empty`     | Named field has a non-empty value.                                                                 | `field: str`                      |
| `field_in_list`       | Named field's value is in an allowlist.                                                            | `field: str`, `values: list[str]` |
| `field_matches_regex` | Named field matches an RE2 regex pattern. Searched anywhere in the value; anchor with `^` and `$`. | `field: str`, `pattern: str`      |

Rule parameter regexes (`field_matches_regex.pattern` and `pii_in_request.patterns`) use RE2 syntax: lookaround and backreferences are rejected when the policy loads. Patterns are searched anywhere in the value; anchor with `^` and `$` for a whole-value match. A template's `name_pattern` also searches anywhere in the value and is case-insensitive.

## Field and property paths

Two kinds of parameter take a path into the step being evaluated: **`field`** (the three field rules and `path_within_root`) and **`property_filter`** (and its relatives `taint_property_filter` and `target_property_filter`: a map of path to the value it must equal). Both use one grammar.

| You write                      | It reads                                                                                                             | Example                                                   |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| a name **with no dot**         | a top-level key of the step: `step_type`, `verb`, `step_name`, and then each top-level key of the step's `input`     | `step_name`, `recipient`                                  |
| a **dotted path**              | the same path inside the step's `properties`: `message.recipient` is `properties["message"]["recipient"]`            | `message.recipient`, `data.classification`, `target.host` |
| a name at `agent_registration` | a field of the agent record being registered: `name`, `purpose`, `owner_id`, `declared_tools`, `risk_classification` | `purpose`                                                 |

There is no prefix to write. **`properties.` and `input.` are not prefixes**: `properties.message.recipient` looks for `properties["properties"]["message"]["recipient"]` and finds nothing. The groups you put after the first dot are whatever the step's `properties` hold. The [standard groups](/core-concepts/behaviours.md#properties) are `target`, `auth`, `data`, `model`, `exec`, `guard`, `message` and `usage`; custom groups are allowed, and the template that built the step decides which are filled (for `send_email` under the decorator template, `message.recipient`; open the trace and read the step's `properties`, which is what the engine sees).

Three behaviours to know before you rely on a path:

* **A path that does not exist reads as "absent" during enforcement.** The engine cannot validate a path at load time because `properties` is open-ended. What absent means depends on the rule: `field_not_empty` and `field_in_list` violate, `field_matches_regex` sees an empty string (so a pattern that does not match the empty string violates), and a `property_filter` entry simply never matches, so a `tainted_path_block` or `history_contains` with a misspelt filter allows everything. [`kyvvu try`](/cli-reference/try.md) warns after a run when a step policy's `field` or property filter path resolves on none of its trial steps. It still exits 0 if the run enforced policies; check the warning and the decisions together.
* **In a `property_filter` on a history rule, use dotted keys only.** `history_contains`, `tainted_path_block` and every `target_property_filter` on the path rules match earlier steps by their `properties` alone, so a filter key without a dot (`flag`, `step_name`) never matches. `current_is` tests the step about to run, so there an undotted `step_name` or `recipient` works as in the table above.
* **An `input` key with the same name as `step_type`, `verb`, `step_name` or `properties` replaces it** in what the rules see, because the input is merged over the top level. Do not name a tool argument one of those.

The filter values are compared with equality (`data.classification: pii` matches exactly `pii`); all entries of one filter must match. Regexes (`field_matches_regex`) are searched anywhere in the value.

### Worked example

This manifest has one compound policy that reads `message.recipient` and one path policy that filters on `data.classification`. The first is a compound because a `step_execution` policy is evaluated on **every** step: a bare `field_matches_regex` on `message.recipient` would violate on every step that has no recipient.

```yaml
name: Path examples
version: "1.0.0"
policies:
  # field reads properties.message.recipient. The policy is evaluated on every
  # step, so it passes any step that is not an outbound message.
  - name: Emails go to approved domains only
    rule_type: any_of
    params:
      conditions:
        - rule_type: not
          params:
            condition:
              rule_type: current_is
              params: {step_type: step.message, verb: POST}
        - rule_type: field_matches_regex
          params:
            field: message.recipient
            pattern: '^[^@]+@(example\.com|partner\.example)$'
    severity: critical
    enforcement_point: step_execution

  # property_filter on a history rule reads properties.data.classification.
  - name: No message after a PII read
    rule_type: tainted_path_block
    params:
      taint_step_type: step.resource
      taint_verb: GET
      taint_property_filter:
        data.classification: pii
      target_step_types: ["step.message"]
      target_verb: POST
    severity: critical
    enforcement_point: step_execution
```

And a path that exercises both, written as steps (`input` is left out; the paths read `properties`):

```yaml
agent:
  agent_id: path-demo
  risk_classification: minimal
  allowed_tools: [read_faq, read_crm, send_email]
steps:
  - {step_name: read_faq, step_type: step.resource, verb: GET}
  - step_name: send_email
    step_type: step.message
    verb: POST
    properties: {message: {recipient: "customer@example.com"}}
  - step_name: send_email
    step_type: step.message
    verb: POST
    properties: {message: {recipient: "bob@evil.io"}}
  - step_name: read_crm
    step_type: step.resource
    verb: GET
    properties: {data: {classification: pii}}
  - step_name: send_email
    step_type: step.message
    verb: POST
    properties: {message: {recipient: "customer@example.com"}}
```

```
$ KV_ENVIRONMENT=development kyvvu try -m paths.yaml --steps steps.yaml
#  DECISION  RISK  BEHAVIOUR                     VIOLATED POLICY
1  ALLOW     0.00  step.resource GET read_faq    -
2  ALLOW     0.00  step.message POST send_email  -
3  BLOCK     1.00  step.message POST send_email  Emails go to approved domains
                                                 only (critical)
4  ALLOW     0.00  step.resource GET read_crm    -
5  BLOCK     1.00  step.message POST send_email  No message after a PII read
                                                 (critical)
```

Step 3 is blocked by the recipient domain (`message.recipient`), step 5 by the PII read at step 4 (`data.classification`). Change `message.recipient` to `message.recpient` in the policy and every email is blocked, including step 2: the path is absent, so the regex sees an empty string. Change `data.classification` to `data.clasification` and step 5 is allowed: the filter matches no step. Both manifests still load, and `kyvvu try` still exits 0, but it warns with the policy name and missing path. Nothing in the loader can tell a typo from a property nobody sets yet.

## Load-time validation

A policy is checked against its rule when it is loaded. A policy whose `params` do not match the rule's `params_schema` (a required key missing, a key the rule does not know, a value of the wrong type, `params` that is not a mapping), an unknown `rule_type`, a duplicate policy name, a regex that does not compile, a step type or verb that is not in the vocabulary (`step.mesage`, `post`), or the retired `scope:` key raises `PolicyLoadError` naming the policy, the rule and the key. Step types and verbs are checked everywhere a rule names one, **including inside the conditions of `all_of`, `any_of` and `not`**; a `field` or `property_filter` path cannot be checked (see [Field and property paths](#field-and-property-paths)). Nothing is skipped and nothing loads partially: a configuration that loaded fewer policies than it names would enforce less than it claims. The same holds for the API validator, `kyvvu try`, `kyvvu doctor` and `kyvvu serve`.

A rule can also need something from the host running it. Such a rule lists `requires` in `GET /api/v1/policies/rules`, and a policy that uses it is refused at load wherever the host does not supply that capability (see `cross_execution_rate_limit` below).

## Path rules

Require task history. `step_execution` only.

| Rule                                   | What it checks                                                                                                                                                                                                                                  | Parameters                                                                                                                                                                                          |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `step_directly_preceded_by`            | The immediately preceding step in history is the required type. Blocked attempts in between are looked past.                                                                                                                                    | `required_step_type: str`, `target_step_types: list[str]` (optional), `target_verb: str` (optional), `target_property_filter: dict` (optional)                                                      |
| `step_requires_predecessor`            | Some earlier step in history has the required type. A blocked step does not count.                                                                                                                                                              | `required_step_type: str`, `target_step_types: list[str]`, `target_verb: str` (optional), `target_property_filter: dict` (optional)                                                                 |
| `step_preceded_by_without_intervening` | A required predecessor exists with no forbidden steps between.                                                                                                                                                                                  | `required_step_type: str`, `forbidden_intervening: list[str]`, `target_step_types: list[str]`, `target_verb: str` (optional), `target_property_filter: dict` (optional)                             |
| `step_requires_dedicated_predecessor`  | Each target attempt, including a blocked one, consumes its own predecessor.                                                                                                                                                                     | `required_step_type: str`, `target_step_types: list[str]`, `target_verb: str` (optional), `target_property_filter: dict` (optional)                                                                 |
| `step_requires_gate`                   | A matching, successful `step.gate` precedes this step. By default the gate may be anywhere in history. Set `max_steps_since_gate` to require it among the last N entries; blocked, failed and in-flight attempts after the gate count toward N. | `target_step_types: list[str]`, `target_verb: str` (optional), `gate_check_type: str` (optional), `gate_result: str` (optional, default `pass`), `max_steps_since_gate: int` (optional, at least 1) |
| `sequence_forbidden`                   | A forbidden ordered sequence (subsequence over history + current step) has not occurred.                                                                                                                                                        | `forbidden_sequence: list[str]`                                                                                                                                                                     |
| `step_not_after`                       | This step type is forbidden once a forbidden predecessor type has occurred (permanently tainted).                                                                                                                                               | `target_step_types: list[str]`, `forbidden_predecessor_step_types: list[str]`, `target_verb: str` (optional)                                                                                        |
| `path_within_root`                     | Allowlist containment: the target path resolves at or under the session `project_root`. Lexical only — `..` is collapsed but symlinks are not resolved; fails open when `project_root` is unknown.                                              | `field: str` (optional, default `target.host`), `target_step_types: list[str]` (optional), `target_verb: str` (optional), `target_verbs: list[str]` (optional)                                      |
| `history_contains`                     | The history contains a step matching type + optional verb + optional property filter.                                                                                                                                                           | `step_type: str`, `count_attempts: bool` (required; true includes blocked attempts), `verb: str` (optional), `property_filter: dict` (optional)                                                     |
| `current_is`                           | The intended behaviour matches type + optional verb + optional property filter.                                                                                                                                                                 | `step_type: str`, `verb: str` (optional), `property_filter: dict` (optional)                                                                                                                        |

For example, `max_steps_since_gate: 3` accepts a passing gate followed by at most two other history entries before the target. A third entry makes that approval stale. A blocked gate cannot approve the target.

## Count rules

| Rule                         | What it checks                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | Enforcement points | Parameters                                                                                                                                            |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `execution_max_steps`        | Task has not exceeded a maximum count of the given step type.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | `step_execution`   | `step_type: str`, `max_steps: int`, `verb: str` (optional), `step_name: str` (optional — count only steps with this name, e.g. cap one specific tool) |
| `max_consecutive_same_type`  | No run of the same step type exceeds a limit.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | `step_execution`   | `step_type: str`, `max_consecutive: int`                                                                                                              |
| `cross_execution_rate_limit` | Agent has not exceeded N of this step\_type in the last M minutes across tasks. **Unavailable:** it needs cross-execution counts (`requires: cross_execution_counts`), which no current host supplies (not the SDK, the runner, or the hosted endpoint), so a policy that uses it is refused at load with `PolicyLoadError` rather than loading and never firing. Use `execution_max_steps` for a per-task cap.                                                                                                                                                                                | `step_execution`   | `step_type: str`, `max_count: int`, `window_minutes: int`, `property_filter: dict` (optional)                                                         |
| `usage_budget`               | Stops further steps of one type once their cumulative numeric property exceeds the budget. Applies only to steps of the budgeted `step_type`; a step of any other type always passes. Counts what is recorded in the task plus the intended step's own value at `property_path` if it declares one in its properties or input before the call, so the step that would cross the budget is the one refused. A cost that is only reported after the call (a token count in the output) cannot be counted in advance, so the block arrives one step late: the budget can be overshot by one step. | `step_execution`   | `step_type: str`, `property_path: str`, `budget: float`                                                                                               |

## Classification rules

| Rule                                | What it checks                                                                                                                                                                                         | Enforcement points | Parameters                                                                                                                                       |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `step_forbidden_for_classification` | This step type is not permitted for the agent's risk classification. The classes are listed exactly in `agent_risk_classifications`; this rule does not apply the "this class or above" reading below. | `step_execution`   | `forbidden_step_type: str`, `agent_risk_classifications: list[str]`, `forbidden_verb: str` (optional), `target_property_filter: dict` (optional) |
| `working_hours_only`                | Current time is within a permitted window. Supports overnight wraparound and timezones.                                                                                                                | `step_execution`   | `start_hour: int`, `end_hour: int`, `timezone: str` (optional, default UTC)                                                                      |
| `step_name_in_allowlist`            | This step's name is in the agent's declared tool allowlist.                                                                                                                                            | `step_execution`   | `agent_field: str`, `target_step_types: list[str]` (optional)                                                                                    |

A *policy's* own `risk_classification` (the field on the policy or manifest entry, not a rule parameter) means **this class or above**, ordered `minimal < limited < high`: a `limited` policy covers limited and high agents, a `minimal` policy covers every classified agent, and no `risk_classification` covers every agent. A class the order does not know (a typo, `unclassified`) also covers every agent. The API's assignment filter and the engine's policy index both use the one helper, `kyvvu_engine.risk`.

## Content rules

| Rule               | What it checks                                                                                                                                                                                                                                                                                                                  | Enforcement points | Parameters                                                              |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------------- |
| `pii_in_request`   | Step input does not contain PII matching configured regex patterns. Patterns are required; no defaults. Input is serialised via `json.dumps` so nested dicts are scanned.                                                                                                                                                       | `step_execution`   | `patterns: list[str]`                                                   |
| `domain_allowlist` | For steps of the listed types, `properties.target.host` is in the allowlist (equal to a listed domain or a subdomain of one). A listed step whose host is absent, empty or blank is a **violation**. Steps of other types are not evaluated. The host is parsed by the rules below; a value that does not parse is a violation. | `step_execution`   | `allowed_domains: list[str]`, `target_step_types: list[str]` (required) |

**How the host is parsed.** A URL (anything containing `://`) and a bare host go through one normaliser, so `API.COMPANY.COM`, `api.company.com:8443`, `api.company.com/path`, `api.company.com.` and `https://api.company.com/x` all mean the same host. The steps are:

1. For a URL, take the authority (between `://` and the first `/`, `?` or `#`). A bare value is treated as `//host` and parsed the same way.
2. **Reject** (violation) an authority that contains a backslash, whitespace, a control character or more than one `@`. A browser reads `\` as a path separator in an http(s) URL where Python's parser does not, so `https://evil.com\.company.com/` names two different hosts to two readers.
3. Take what follows the last `@` (`user:pw@host` is allowed; the host is what counts), then remove any port, lower-case it and remove **one** trailing dot.
4. The result must be a plain DNS name: ASCII letters, digits, hyphens and dots, with `xn--` labels allowed. Unicode dots, homographs and empty labels fail. Anything that fails is a violation (the rule fails closed).
5. Allowed means equal to a listed domain or a subdomain of one. The listed domains are lower-cased and stripped of one trailing dot the same way.

**What it blocks, so you can plan for it:**

* A listed step with **no host**. A `step.message` step carries no `target.host` (its destination is `properties.message.recipient`) unless you set one, so including `step.message` in `target_step_types` blocks every message; check recipients with `field_in_list` or `field_matches_regex` instead, or set the host in a template.
* **Raw IPs** (`10.0.0.5`, `[::1]`): never a subdomain match, and allowed only when the allowlist lists that exact string.
* Bypass shapes: `https://api.company.com.evil.io/`, `https://evil.io/api.company.com`, `https://api.company.com@evil.io/`, a space or `;` in the host, and unicode look-alikes.

`target_step_types` is required; a `domain_allowlist` without it is refused at load. That is deliberate and security-relevant: a step with no host used to be skipped or blocked by accident, so a destination passed in a parameter the template does not read (an `endpoint=` argument, say) could slip past the allowlist. Now every step of a listed type must name an allowed host. Name the types the allowlist is for, typically `["step.resource"]`.

The consequence to plan for: a local `step.resource` that has no network destination, such as a `read_faq` tool, is **blocked** by a `domain_allowlist` whose `target_step_types` include `step.resource`, because its host is absent. Either declare where it goes, with `properties={"target": {"host": "faq.internal.example.com"}}` (or a template rule that sets `target.host`; use `{{ kwargs.url | omit_empty }}` so a missing argument leaves the key out), or keep the step out of the rule's `target_step_types`.

## Flow rules

| Rule                             | What it checks                                                                                                  | Enforcement points | Parameters                                                                                                                                                    |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `conditional_successor_required` | If the previous step matches a trigger type and condition, the current step must satisfy successor constraints. | `step_execution`   | `trigger_step_types: list[str]`, `trigger_condition: dict`, `required_step_type: str` (optional), `forbidden_step_types: list[str]` (optional)                |
| `tainted_path_block`             | If any prior step matches the taint pattern, certain downstream steps are blocked.                              | `step_execution`   | `taint_step_type: str`, `target_step_types: list[str]`, `taint_verb: str` (optional), `taint_property_filter: dict` (optional), `target_verb: str` (optional) |
| `all_of`                         | Compound: passes iff all sub-conditions pass.                                                                   | Both               | `conditions: list[condition]`                                                                                                                                 |
| `any_of`                         | Compound: passes iff any sub-condition passes.                                                                  | Both               | `conditions: list[condition]`                                                                                                                                 |
| `not`                            | Compound: passes iff the sub-condition fails.                                                                   | Both               | `condition: condition`                                                                                                                                        |

Each sub-condition in compound rules is a dict with `rule_type` and `params` keys.

## Programmatic discovery

Rules are self-documenting. To list all rules programmatically:

```python
from kyvvu_engine import PolicyRule

# All rules for a given enforcement point
rules = PolicyRule.get_all_rules(enforcement_point="step_execution")
# → {"field_not_empty": {"description": "...", "enforcement_points": [...], "params_schema": {...}}, ...}

# Does a rule exist, and do these params match its schema?
assert PolicyRule.rule_exists("pii_in_request")
problems = PolicyRule.check_params("pii_in_request", {"patterns": ["\\d{3}-\\d{2}-\\d{4}"]})
assert problems == []   # a list of messages, one per problem; empty when the params are valid
```

`check_params` is the same check the engine runs when it loads a policy and the API runs when you create one. The metadata is also what `GET /api/v1/policies/rules` serves.

***

## Next steps

* [Creating Policies](/policy-authoring/creating.md) — use these rules to author policies
* [Compound Policies](/policy-authoring/compound.md) — combine rules with `all_of`, `any_of`, `not`
* [OWASP Default Template](/policy-authoring/owasp-default.md) — see 8 of these rules in action
