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

FieldMeaning
name[a-z0-9_-], 1–64, unique after group expansion
actionallow, block, challenge, drop, count
routesevaluated routes the rule applies to
matchthe conditions below; everything present must hold
scope_downa second match; the rule (and its rate counter) only applies where it holds
ratemakes the rule rate-based (below)
responsecustom response for block (and for challenge on /agent/submit)
group, version, overridesa 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.

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

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.

View as Markdown