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.
Where rule sets come from
- The server's rule set. The built-in default is the managed groups
agentgate-core@1,agentgate-agents@1andagentgate-gateway@1. An operator replaces it with arules.jsonfile (orRULES_FILE) at start, or at run time withPOST /admin/ruleson the private admin listener. - Per-site rules. In the console (site page, Rules) or with
PUT /v1/console/sites/{siteKey}/rulesa site can store its own rule set, or keep the server's and override single rules' actions. Validate first withPOST …/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.
{
"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:
{"name": "api_scrapers", "action": "challenge",
"match": {"any_prefix": ["agentgate:ip:cloud:"]},
"scope_down": {"path": {"prefix": ["/api/"]}, "method": ["GET"]}}Rate-based rules
{"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.
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
"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.