# Rule language

> The complete WAF-style rule language: rule fields, match conditions, scope-down, rate-based rules, immunity time and custom responses.

Every request AgentGate evaluates gets labels from its signal sources; an
ordered rule set turns labels, request facts and rates into an action. For
the ideas behind it, start with [Rules and managed groups](/docs/rules-overview).

## Where rule sets come from

- **The server's rule set.** The built-in default is the managed groups
  `agentgate-core@1`, `agentgate-agents@1` and `agentgate-gateway@1`. An
  operator replaces it with a `rules.json` file (or `RULES_FILE`) at start,
  or at run time with `POST /admin/rules` on the private admin listener.
- **Per-site rules.** In the console (site page, **Rules**) or with
  `PUT /v1/console/sites/{siteKey}/rules` a site can store its own rule set,
  or keep the server's and override single rules' actions. Validate first
  with `POST …/rules/validate`. The effective version,
  `<base>+site.r<revision>`, is recorded on every decision.

Every audit event and decision lists the labels and the deciding rule.

## Rule language reference

A rule set is JSON. Every way of installing one validates it first: every
error names the rule, regular expressions are compiled once at that point,
and a rule set that fails validation is never installed.

```json
{
  "version": "site-rules-7",
  "default_action": "allow",
  "immunity_seconds": 1800,
  "ip_sets": {"partners": ["198.51.100.0/24", "2001:db8:1::/48"]},
  "rules": [
    {"group": "agentgate-core", "version": 1, "overrides": {"agent_class": "count"}},
    {"group": "agentgate-bot-control", "version": 1},
    {"name": "allow_partners", "action": "allow", "match": {"ip_set": {"name": "partners"}}},
    {"name": "form_flood", "action": "block", "routes": ["/submit"],
     "rate": {"limit": 20, "window_seconds": 60, "keys": ["ip_prefix24"]},
     "response": {"status": 429, "retry_after": 60, "body": {"error": "rate_limited"}}},
    {"group": "agentgate-agents", "version": 1}
  ]
}
```

Rules are evaluated in order. The first matching rule whose action is not
`count` decides; `count` rules add `agentgate:rule:<name>` and continue (every
matching rule adds that label). `routes` limits a rule to evaluation routes: `/submit` (the form and every
browser SDK verification), `/agent/submit` (the agent API) and
`/v1/gateway/check` (the gateway); omitted means every route.

### Rule fields

| Field | Meaning |
| --- | --- |
| `name` | `[a-z0-9_-]`, 1–64, unique after group expansion |
| `action` | `allow`, `block`, `challenge`, `drop`, `count` |
| `routes` | evaluated routes the rule applies to |
| `match` | the conditions below; everything present must hold |
| `scope_down` | a second match; the rule (and its rate counter) only applies where it holds |
| `rate` | makes the rule rate-based (below) |
| `response` | custom response for `block` (and for `challenge` on `/agent/submit`) |
| `group`, `version`, `overrides` | a managed rule group reference instead of a rule |

### Match conditions

All conditions present in one `match` object must hold (AND). Label and score
conditions read what sources attached; statements read the request.
Statements that need the request (everything except labels, scores and
`verified_agent`) are false when no request data exists.

| Condition | Example | Matches when |
| --- | --- | --- |
| `all_labels` | `{"all_labels": ["agentgate:tls:non_browser", "agentgate:ua:browser"]}` | every label is present |
| `any_labels` | `{"any_labels": ["agentgate:rate:session_exceeded", "agentgate:rate:global_exceeded"]}` | at least one is present |
| `none_labels` | `{"all_labels": ["agentgate:bot:risk_high"], "none_labels": ["agentgate:session:passed"]}` | none is present |
| `any_prefix` | `{"any_prefix": ["agentgate:ip:cloud:"]}` | some label starts with a prefix (counts with `any_labels`) |
| `score` + `min_score` | `{"score": "ip_reputation", "min_score": 0.8}` | the score exists and is ≥ `min_score` |
| `ip_set` (inline) | `{"ip_set": {"cidrs": ["203.0.113.0/24", "2001:db8::/32", "192.0.2.7"]}}` | client address is in a CIDR (v4/v6, bare address = host) |
| `ip_set` (named) | `{"ip_set": {"name": "partners"}}` | client address is in the rule set's `ip_sets` entry |
| `country` | `{"country": ["KP", "IR"]}` | AS registration country (upper-case ISO 3166 alpha-2) is listed; unknown never matches |
| `asn` | `{"asn": [14061, 16509]}` | origin AS is listed |
| `cloud` | `{"cloud": ["aws", "gcp"]}` or `{"cloud": ["any"]}` | address is in a provider's published ranges: `aws`, `gcp`, `azure`, `oracle`, `cloudflare`, `digitalocean` |
| `tor` | `{"tor": true}` | address is (`true`) or is not (`false`) a current Tor exit |
| `header` exact | `{"header": {"name": "Accept", "exact": ["*/*"]}}` | any value equals one listed |
| `header` prefix / suffix / contains | `{"header": {"name": "User-Agent", "prefix": ["curl/", "python-requests/"]}}` | any value starts with / ends with / contains one listed |
| `header` regex set | `{"header": {"name": "User-Agent", "regex": ["(?i)headless", "^Go-http-client/"]}}` | any value matches any pattern |
| `header` case-insensitive | `{"header": {"name": "Accept-Language", "exact": ["en-us"], "ignore_case": true}}` | as above, ignoring case |
| `header` present / absent | `{"header": {"name": "Sec-Fetch-Site", "absent": true}}` | the header is sent / not sent |
| `header` size | `{"header": {"name": "Cookie", "min_size": 4096}}` | total bytes of its values within `min_size`..`max_size` (size and text conditions combine) |
| `query` | `{"query": {"name": "debug", "present": true}}`, `{"query": {"name": "q", "max_size": 256}}` | the same options, on a URL query parameter |
| `path` | `{"path": {"prefix": ["/wp-admin", "/.env"]}}`, `{"path": {"regex": ["^/api/v[0-9]+/export$"]}}` | URL path, with `exact`/`prefix`/`suffix`/`contains`/`regex`/`ignore_case` |
| `method` | `{"method": ["PUT", "DELETE"]}` | request method (upper case) |
| `ja4_prefix` | `{"ja4_prefix": ["t13d1516h2", "t12"]}` | the JA4 fingerprint starts with a prefix (no JA4 never matches) |
| `verified_agent` | `{"verified_agent": ["chatgpt", "googlebot"]}` | `agentgate:agent:verified:<name>` (Web Bot Auth), or a catalogued bot proven by signature or published IP range |
| `and` | `{"and": [{"method": ["POST"]}, {"path": {"exact": ["/login"]}}]}` | every nested match holds |
| `or` | `{"or": [{"tor": true}, {"cloud": ["any"]}]}` | at least one nested match holds |
| `not` | `{"not": {"header": {"name": "Accept-Language", "present": true}}}` | the nested match does not hold |

Nested matches take every condition in this table, to a depth of 8. Limits,
enforced at validation: 128 statements per rule, 64 values per text match,
regexes 1–512 bytes and 512 per rule set (RE2 syntax, Go `regexp`: linear
time, no backreferences or lookaround), 1,024 inline CIDRs, 64 named sets of
up to 10,000 CIDRs. The client address is the connection's, or `X-Real-IP`
from a loopback proxy only (as elsewhere).

`scope_down` narrows a rule without changing its match; it is most useful on
rate-based rules and group references:

```json
{"name": "api_scrapers", "action": "challenge",
 "match": {"any_prefix": ["agentgate:ip:cloud:"]},
 "scope_down": {"path": {"prefix": ["/api/"]}, "method": ["GET"]}}
```

### Rate-based rules

```json
{"name": "per_agent_quota", "action": "block", "routes": ["/agent/submit"],
 "rate": {"limit": 100, "window_seconds": 300, "keys": ["agent"]},
 "response": {"status": 429, "retry_after": 300}}
```

`limit` 1–1,000,000 requests per `window_seconds` (60–600). `keys` (1–5,
combined): `ip`, `ip_prefix24` (/24 for IPv4, /48 for IPv6), `asn`,
`session`, `agent` (verified agent or bot name), `site` (the request's Host header), `header:<name>`, `label:<prefix>` (the first label with that
prefix, e.g. `label:agentgate:tls:ja4:`). A rate rule counts every request
that reaches it and satisfies its `match` and `scope_down`, and matches once
that key's count exceeds the limit. `match` may be empty on a rate rule. A
request missing a key component (no ASN data, no verified agent) is not
counted and does not match. The window slides (the previous fixed window is
weighted by its remaining overlap). Counters are **process-local and in
memory** and kept per site (sites never share a counter): they reset on
restart, are not shared between instances, keep at
most 20,000 keys per rule (least recently seen evicted) and 1,024 rules;
reloading an unchanged rule keeps its counts.

### Managed rule group references

A reference `{"group": "<name>", "version": N}` expands to that group's
rules in place. `overrides` changes single rules' actions (for example to
`count` while you evaluate them) and `scope_down` is ANDed into every rule
of the group. Every group, version and rule is listed on
[Managed rule groups](/docs/managed-rule-groups).

### Immunity time

On the session-based form flow (the demo form and `/gate.js`), after a
session passes the visible check, `/challenge/verify` sets the signed
`ag_pass` cookie for `immunity_seconds` (60–86,400; default 900, the previous
fixed 15 minutes). While it is valid the request carries
`agentgate:session:passed`; the built-in challenge rules exclude that label,
and any other rule whose action is `challenge` is skipped like a `count` rule
(label `agentgate:immunity:applied`). Blocks still apply. The window is fixed
when the pass is issued; lowering `immunity_seconds` affects new passes only.

### Custom responses

```json
"response": {"status": 403, "retry_after": 0,
             "body": {"error": "forbidden", "docs": "https://example.com/bots"},
             "headers": {"Cache-Control": "no-store", "X-Reason": "bot-control"}}
```

`body` is a JSON value sent as `application/json`, or a JSON string sent as
`text/plain` (`"body": "go away"`); `content_type` overrides the type. At most
8 KiB, always with `X-Content-Type-Options: nosniff`. `headers` allows
`Cache-Control`, `Content-Language`, `Link`, `Location`, `Vary`,
`WWW-Authenticate`, `Access-Control-Allow-Origin`,
`Access-Control-Expose-Headers`, `Crawler-Price`, `Crawler-Charged` and
`X-*` (not `X-Accel-*`, `X-Sendfile`, `X-Forwarded-For`, `X-Real-IP`); never
`Set-Cookie`, hop-by-hop or framing headers. `Retry-After` is the
`retry_after` field. Without `body`, `headers` or `content_type` the response
is `message` as plain text, as before. Challenges on `/submit` always answer
with the challenge JSON; `drop` always answers like an acceptance.
