# Rules
Rules are how an operator decides what happens to a request:
forward it, reject it, or route it through one or more
approvers — a human acting from the dashboard or Slack, an
LLM judging against a policy, or both in sequence (every approver
must allow). Each rule is a block in gateway.hcl that targets
one or more endpoints, describes
which requests it applies to (the condition CEL expression),
and declares the outcome (verdict = "allow" / "deny", or
approve = [...]).
There is one rule kind. The rule's protocol family — http,
sql, or k8s — is inferred from its endpoint(s) at load time and
pins the set of CEL variables the condition may reference. An
http endpoint exposes http.method / http.path / …; a postgres
or clickhouse endpoint exposes sql.verb / sql.tables / …; a
kubernetes endpoint exposes k8s.verb / k8s.resource / …. A rule
whose endpoints = [...] mixes families is a load error.
This page covers the operator's view: how to write a rule, what each facet does, and how rules behave in different situations.
For the surrounding picture see Architecture — request flow, where matching fits, how endpoints claim requests.
# Rule families
Each endpoint claims requests and emits actions of a specific
family. Each action carries the family's facets, and rules match
against those facets via a CEL condition expression. See
Architecture for how endpoints claim requests
in the first place.
#
http family
Bound to http endpoints. The condition is evaluated against the
parsed HTTP request before it is forwarded upstream, after MITM
has terminated TLS.
Example: require approval for a specific support-ticket mutation.
rule "support-ticket-status" {
endpoint = http.console
condition = "http.method == 'POST' && http.path == '/api/admin.supportTickets.updateStatus'"
approve = [human_approver.support]
}
CEL variables (all optional in any given condition):
| Variable | Type | Description |
|---|---|---|
http.method | string | HTTP verb. Lowercased at activation time; literal 'POST' in rule source is normalized to 'post' at compile time so either case works. |
http.path | string | Request path (no query string) |
http.query | map<string, list<string>> | Query parameters (multi-valued) |
http.headers | map<string, list<string>> | Request headers (multi-valued) |
http.body | string | Raw request body |
http.body_json | dyn | Parsed JSON body (when Content-Type is JSON) |
condition = "http.method == 'POST' && http.path in ['/v1/refunds', '/v1/payouts']"
condition = "http.method in ['GET', 'HEAD']"
condition = "http.body.contains('BEGIN PRIVATE KEY')"
condition = "http.body_json.archived == true"
#
sql family
Bound to sql endpoints (postgres, clickhouse_https,
clickhouse_native). The condition runs against every parsed SQL
statement the agent sends.
Example: block filesystem-reaching Postgres functions.
rule "pg-banned-functions" {
endpoint = postgres.pg-staging
condition = "sets.intersects(sql.functions, ['pg_read_file', 'pg_read_binary_file', 'lo_get'])"
verdict = "deny"
}
| Variable | Type | Description |
|---|---|---|
sql.verb | string | First verb of the statement (lower-case: "select", …) |
sql.tables | list<string> | Tables referenced by the statement |
sql.functions | list<string> | Functions called by the statement |
sql.statement | string | The full lower-cased statement text |
sql.database | string | Agent-declared target database. Postgres reads it from the StartupMessage database (with user fallback). clickhouse_native reads Hello.Database. clickhouse_https reads ?database= query first, then X-ClickHouse-Database header. Empty when neither set. |
condition = "sql.verb in ['select', 'show', 'explain']"
condition = "'secrets' in sql.tables"
condition = "sets.intersects(sql.tables, ['users', 'audit_log'])"
condition = "sql.statement.matches('(?i)\\bpassword\\b')"
condition = "sql.database == 'prod'"
verb, tables, and functions are extracted by a best-effort
lexer over a lower-cased copy of the statement — see
Case sensitivity below.
tables and functions are multi-valued facets: a single
statement can name several tables (SELECT ... FROM a JOIN b) and
call several functions. Use CEL's in operator for a single name
('secrets' in sql.tables) or sets.intersects(...) for an overlap
test against a list. To require every extracted name be covered,
write the condition against sql.statement with a regex
(sql.statement.matches(...)).
#
k8s family
Bound to kubernetes endpoints. The condition sees the
(verb, resource, namespace, name, params) tuple Claw Patrol parses
out of the kubernetes API path.
Example: deny Kubernetes Secret reads.
rule "k8s-no-secrets" {
endpoint = kubernetes.k8s-prod
condition = "k8s.resource == 'secrets'"
verdict = "deny"
}
| Variable | Type | Description |
|---|---|---|
k8s.verb | string | HTTP-derived verb ("list", "get", "create", …) |
k8s.resource | string | <resource> or <resource>/<sub> for subresources |
k8s.namespace | string | Kubernetes namespace |
k8s.name | string | Resource name |
k8s.params | map<string, string> | Query-string params (e.g. kubectl exec --stdin) |
condition = "k8s.verb in ['create', 'delete'] && k8s.resource == 'pods'"
condition = "k8s.resource in ['pods/exec', 'pods/attach']"
condition = "!k8s.name.startsWith('debug-')"
condition = "!k8s.resource.endsWith('/exec') && !k8s.resource.endsWith('/attach')"
A rule bound to http endpoints sees http.* only; a rule bound
to kubernetes endpoints sees k8s.* only. Mixing families across
a rule's endpoints = [...] is a load error.
ssh endpoints exist but have no rule family yet — the gateway
terminates auth and splices channels as opaque byte streams, emitting
a single allow event at session start. Rules cannot gate anything
inside an SSH session today.
# How to create a rule
Every rule shares the same outer skeleton. Field-by-field:
rule "<name>" {
endpoint = <endpoint-name> # singular: bare-name ref
# endpoints = [<a>, <b>] # OR list form (mutually exclusive)
priority = 100 # default 0; higher wins
credential = <credential-name> # optional: only match when
# the dispatched credential is this one
condition = "<CEL expression>" # absent / empty == match-all
verdict = "allow" # OR
# verdict = "deny" # OR
# approve = [<approver>, ...] # bare-name refs to approver blocks
reason = "destructive money movement"
# disabled = true # keep in source, skip evaluation
}
| Field | Required? | Notes |
|---|---|---|
endpoint / endpoints | exactly one | Bare-name refs to declared endpoints. All endpoints must share one protocol family. |
priority | optional (default 0) | Higher fires first. Negative for catch-alls (-100 is the convention). |
credential | optional | Bare-name ref. The runtime treats it as an extra predicate evaluated before the CEL condition: the request must have been dispatched against this credential. |
condition | optional | A CEL string evaluated against the family's variable set. Absent or empty matches every request the endpoint sees. |
verdict | one of verdict / approve | "allow" or "deny". |
approve | one of verdict / approve | List of approver bare names. Approvers run in order; all must allow for the request to proceed. |
reason | optional | Surfaced to the agent on deny / approver-deny, and shown on the dashboard. |
disabled | optional | Keeps the rule in source but suppresses it at compile time. |
Naming: every named entity in gateway.hcl (approvers, credentials,
endpoints, rules, profiles) shares one flat namespace. References
are bare names — never endpoint.foo or credential.foo. A
duplicate name across kinds is a load error.
A rule that names an undeclared endpoint, mixes endpoint families, or has a CEL expression that references variables not in the inferred family fails at load time with an error pointing at the offending block.
# Matching semantics
# Endpoint and action
Each endpoint plugin claims the requests it owns and emits an
action in its family — http actions for HTTPS endpoints, sql
actions for postgres / clickhouse, k8s actions for kubernetes.
Each action populates the family's CEL variables (method/path/headers
for HTTP, verb/tables/functions for SQL, resource/verb/namespace for
k8s). The rule's condition is evaluated against those variables.
How an endpoint claims a given connection (SNI peek, destination IP, profile scoping) is described in Architecture. If no endpoint claims the flow, no rule evaluation happens — the connection is passed through verbatim.
# Priority and first-match-wins
Each endpoint's rules are sorted by priority at compile time
(descending — higher priority first). The runtime walks them in
order and returns the first rule whose credential predicate (if
set) matches and whose CEL condition evaluates true.
Within a priority bucket, declaration order is the tiebreaker: two rules at the same priority that both match — the one written first in the HCL wins.
disabled = true rules are skipped entirely.
# CEL condition basics
Each family exposes one struct-typed top-level variable. Fields are accessed with dot notation. Common idioms:
- Equality / membership:
http.method == 'POST',sql.verb in ['select', 'show']. - Prefix / suffix / substring:
k8s.name.startsWith('debug-'),k8s.resource.endsWith('/exec'),http.body.contains('secret'). - Regex (when prefix / suffix isn't enough):
sql.statement.matches('(?i)\\bpassword\\b'). Regex is unanchored Go RE2 — add^/$if you mean it. - List intersection (any-of against a multi-valued facet):
sets.intersects(sql.tables, ['users', 'audit_log']). Thesetsextension is registered on every facet env. - Negation: prepend
!to any boolean expression.!k8s.name.startsWith('debug-').
# Case sensitivity, by variable
| Variable | Case sensitivity |
|---|---|
http.method | lower-case (rule-source literals normalized at compile time) |
http.path, http.query, http.headers, http.body | as on the wire |
sql.verb | lower-case (normalized) |
sql.tables, sql.functions | lower-case (extracted from a lower-cased copy of the statement) |
sql.statement | as on the wire (raw text, no case folding) |
sql.database | as on the wire (StartupMessage / Hello / HTTP query+header) |
k8s.verb | lower-case (normalized) |
k8s.resource, k8s.namespace, k8s.name, k8s.params | as on the wire |
For SQL, the parser lower-cases an internal copy of the statement
before extracting verbs, tables, and functions — so
'Users' in sql.tables will never fire. Write literals in the same
case the parser will produce (lower). sql.statement itself is the
raw on-the-wire text; match it case-blindly with a (?i) regex
flag (sql.statement.matches('(?i)\\bpassword\\b')).
#
credential = X
credential is a top-level attribute on the rule, not part of the
CEL condition. It does not look at the request body or headers — it
matches the resolved credential name, not the credential's secret
contents. It is checked before the CEL condition.
# Outcome dispatch
After a rule matches:
verdict = "allow"— the request is forwarded.verdict = "deny"— the request is rejected. HTTP gets a 403 withreasonin the body; postgres gets anErrorResponseframe carryingreason.approve = [a, b, c]— approvers run in order, all must allow. The first non-allow approver short-circuits and is returned. An approver that returns no decision (e.g. timeout) is treated as deny.
LLM approvers call the configured model via its bound credential and
judge the request against the approver's policy. Human approvers park
the request on the dashboard's pending-approvals page. If the approver
block has a credential reference to a slack_tokens credential, Claw
Patrol also posts an approval message to the configured Slack channel.
By default the message carries a link back to the dashboard; setting
interactive = true on the approver embeds in-channel "approve" and
"deny" buttons so the reviewer can decide without leaving Slack.
# Default allow
If no rule matches, the request is allowed — there is no global
default-deny. Add a priority = -100, verdict = "deny" catch-all
per endpoint to invert this.
# Synchronous human approval and timeouts
Human approval is synchronous in the transparent proxy path. When a
matched rule declares approve = [...], Claw Patrol pauses the original
request before contacting upstream and waits for the approver chain to
allow or deny.
If every approver allows, Claw Patrol forwards the request upstream. If any approver denies, an approver times out, or the client disconnects before a final allow decision, Claw Patrol does not call upstream. Deny and timeout responses are gateway-generated failures, not upstream responses.
For human_approver, set timeout to the maximum time Claw Patrol
should wait for a human decision.
# Recommended timeout values
Recommended starting configuration:
- Claw Patrol human approval timeout:
90seconds - Agent or tool caller timeout:
240seconds
The caller timeout must exceed Claw Patrol's approval timeout — otherwise the caller gives up locally before the gateway can return its allow/deny result. The absolute minimum margin is the network round-trip plus a small buffer (60 seconds is plenty); the example above leaves ~150 seconds of headroom, which is the comfortable default.
# Example: OpenClaw configuration
For a normal OpenClaw agent run, configure the overall agent-run timeout:
openclaw config set agents.defaults.timeoutSeconds 240
For OpenClaw exec calls, also set the per-command timeout:
openclaw config set tools.exec.timeoutSec 240
We also recommend adding guidance to AGENTS.md or the agent's system
instructions telling the agent to keep inner HTTP timeouts above Claw
Patrol's approval timeout when it writes curl, HTTP client, or script
code. Otherwise the inner client times out locally and the agent never
sees the deny response Claw Patrol synthesizes on approval timeout.
# Inspection-buffer overflow
To bound memory, the wire endpoints cap how much of each request they buffer for the matcher. A request that exceeds its cap is not dropped on the floor — the frame still forwards to upstream byte-for-byte. What's bounded is the matcher's view of it: the endpoint truncates the buffered slice and flags the request as truncated. The facet fields that draw their value from this slice are truncatable facet fields (listed per-endpoint in the table below). When a rule's CEL reads a truncatable facet field on a request that was flagged truncated, the rule is automatically matched without comparing the matching values, and the dispatcher returns a deny verdict for it.
| Endpoint | Inspected slice | Cap | Truncatable facet fields |
|---|---|---|---|
http | request body on POST / PUT / PATCH | 1 MiB | http.body, http.body_json |
kubernetes | request body on POST / PUT / PATCH | 1 MiB | (none — every k8s.* facet is derived from the URL and method) |
clickhouse_https | request body on POST / PUT / PATCH | 1 MiB | sql.verb, sql.tables, sql.functions, sql.statement |
postgres | Query (Q) and Parse (P) frame | 1 MiB | sql.verb, sql.tables, sql.functions, sql.statement |
clickhouse_native | Query packet body | 1 MiB | sql.verb, sql.tables, sql.functions, sql.statement |
The caps are per-plugin constants in the gateway source — not
operator-tunable today, and not surfaced in gateway.hcl. Header
and URL bytes are bounded separately by net/http's defaults and
aren't covered here; the ssh endpoint has no rule family, so no
inspection cap.
# Rule matching semantics on truncated fields
When a request overflows its cap, the dispatcher walks the endpoint's rules in priority order as usual. For each rule:
Catch-all rule (no
condition): fires as written. A truncated body can't poison a rule that reads nothing.Rule whose CEL reads no truncatable facet field (e.g.
http.method == 'GET',credential = X, anyk8s.*predicate): the matcher runs normally — the truncated bytes are irrelevant to its decision.Rule whose CEL reads a truncatable facet field: the rule is automatically matched without comparing the matching values. The dispatcher synthesizes a deny attributed to that rule, with this exact reason:
rule "<name>" reads a request facet whose bytes were truncated by the gateway's inspection buffer; failing closedThe synthesized rule keeps the original rule's name and priority, so logs and dashboards still attribute the deny to the rule whose contract the truncation broke.
The upshot: a rule matching on http.method and/or credential on
an http endpoint still fires on a 2 MiB body, but a
http.body_json.field == "x" rule auto-denies.
A matched rule with approve = [...] on a truncated postgres frame
is forced to deny without paging the approver (HITL can't reason about
bytes that aren't there); the postgres endpoint surfaces this with the
reason "approval required but request was truncated by inspection buffer".
# How the deny reaches the agent
Each protocol synthesizes the deny in its native shape so the agent's driver doesn't disconnect:
http,kubernetes,clickhouse_https—HTTP/1.1 403 Forbidden,Content-Type: text/plain, reason in the body,Connection: close.postgres—ErrorResponse(severityERROR, SQLSTATE42501, message = reason), followed byReadyForQueryin idle state. The session stays open; the agent can run the next query.clickhouse_native— serverExceptionpacket with the reason. The unread tail of the oversizeQuerybody is drained off the wire so the next packet frames correctly.
# Why fail-closed
A truncated body might contain content that would have triggered a deny rule the gateway can't see, so refusing is the safe default. If legitimate traffic is expected to exceed the cap, write the rules against non-truncatable facet fields only (see the table above) — those rules still match on a truncated request and won't auto-deny.
# Examples
These are trimmed, public-safe versions of real policies. They show the same layering pattern across families: hard denies first, explicit allows next, then a low-priority default deny.
# HTTP: support ticket mutations
This policy allows console reads, routes specific support-ticket mutations to humans, runs outbound support replies through an LLM proctor before human review, and denies everything else.
credential "cookie_token" "console-session" {
cookie_name = "token"
}
credential "anthropic_manual_key" "anthropic-key" {}
credential "slack_tokens" "support-slack" {}
endpoint "http" "console" {
hosts = ["console.example.com"]
credential = cookie_token.console-session
}
approver "llm_approver" "reply-content-judge" {
model = "claude-haiku-4-5-20251001"
credential = anthropic_manual_key.anthropic-key
policy = <<-EOT
The JSON body has a body field containing a customer support reply.
Deny markdown formatting, missing required context, offensive
content, impersonation, and account-harming instructions.
EOT
}
approver "human_approver" "support-triage" {
channel = "#support"
credential = slack_tokens.support-slack
interactive = true
timeout = 90
}
rule "console-reads" {
endpoint = http.console
condition = "http.method == 'GET'"
verdict = "allow"
}
rule "console-ticket-mutations" {
endpoint = http.console
condition = <<-CEL
http.method == 'POST'
&& http.path in [
'/api/admin.supportTickets.markAsSpam',
'/api/admin.supportTickets.updateStatus',
]
CEL
approve = [human_approver.support-triage]
}
rule "console-reply-on-behalf" {
endpoint = http.console
condition = <<-CEL
http.method == 'POST'
&& http.path == '/api/admin.supportTickets.replyOnBehalf'
CEL
approve = [
llm_approver.reply-content-judge,
human_approver.support-triage,
]
}
rule "console-default" {
endpoint = http.console
priority = -100
verdict = "deny"
reason = "console mutations require an explicit approval rule"
}
The LLM approver runs first on the reply path. If it denies, no human is paged. If it allows, the same request still needs human approval.
# Kubernetes: deny unsafe cluster operations
This example gates several clusters with one shared rule set. It blocks secret reads and interactive shells at high priority, allows ordinary reads, permits debug pod workflows, and denies anything not explicitly covered.
credential "mtls_credential" "k8s-client" {}
endpoint "kubernetes" "k8s-dev" {
server = "https://k8s-dev.example.com"
ca_cert = "<<file:k8s-dev-ca.pem>>"
credential = mtls_credential.k8s-client
}
endpoint "kubernetes" "k8s-staging" {
server = "https://k8s-staging.example.com"
ca_cert = "<<file:k8s-staging-ca.pem>>"
credential = mtls_credential.k8s-client
}
rule "k8s-no-secrets" {
endpoints = [kubernetes.k8s-dev, kubernetes.k8s-staging]
priority = 1000
condition = "k8s.resource == 'secrets'"
verdict = "deny"
reason = "Secret values must not leave the cluster via the agent"
}
rule "k8s-no-interactive" {
endpoints = [kubernetes.k8s-dev, kubernetes.k8s-staging]
priority = 1000
condition = <<-CEL
k8s.resource in ['pods/exec', 'pods/attach']
&& k8s.params.stdin == 'true'
CEL
verdict = "deny"
reason = "Interactive shells cannot be evaluated by the rules engine"
}
rule "k8s-no-mutations" {
endpoints = [kubernetes.k8s-dev, kubernetes.k8s-staging]
condition = <<-CEL
k8s.verb in ['create', 'update', 'patch', 'delete']
&& !k8s.name.startsWith('debug-')
&& !k8s.resource.endsWith('/exec')
&& !k8s.resource.endsWith('/attach')
&& !k8s.resource.endsWith('/portforward')
CEL
verdict = "deny"
reason = "Only debug-* pods may be created / modified / deleted"
}
rule "k8s-reads" {
endpoints = [kubernetes.k8s-dev, kubernetes.k8s-staging]
condition = "k8s.verb in ['get', 'list', 'watch']"
verdict = "allow"
}
rule "k8s-debug-pods" {
endpoints = [kubernetes.k8s-dev, kubernetes.k8s-staging]
condition = <<-CEL
k8s.verb in ['create', 'delete']
&& k8s.resource == 'pods'
&& k8s.name.startsWith('debug-')
CEL
verdict = "allow"
}
rule "k8s-dev-default" {
endpoint = kubernetes.k8s-dev
priority = -100
verdict = "deny"
}
rule "k8s-staging-default" {
endpoint = kubernetes.k8s-staging
priority = -100
verdict = "deny"
}
The k8s-no-mutations rule demonstrates the usual negation pattern:
match the broad mutating class, then carve out narrowly scoped debug
exceptions.
# SQL: Postgres reads, mutations, and secret tables
SQL policies commonly hard-deny schema or filesystem-reaching shapes, route small DML through a human, proctor sensitive reads with an LLM, allow ordinary reads, and default-deny unknown verbs.
credential "postgres_credential" "pg-console" {
user = "console"
}
credential "anthropic_manual_key" "anthropic-key" {}
credential "slack_tokens" "db-slack" {}
endpoint "postgres" "pg-staging" {
host = "pg-staging.example.com:5432"
sslmode = "verify-full"
credential = postgres_credential.pg-console
}
approver "human_approver" "db-review" {
channel = "#agent-db"
credential = slack_tokens.db-slack
interactive = true
timeout = 90
}
approver "llm_approver" "pg-secret-columns-judge" {
model = "claude-haiku-4-5-20251001"
credential = anthropic_manual_key.anthropic-key
policy = <<-EOT
Deny SELECTs that project raw secret material: access tokens,
refresh tokens, password hashes, cert private keys, or secret env
var values. Allow metadata-only reads such as ids, names, counts,
and timestamps.
EOT
}
rule "pg-no-ddl" {
endpoint = postgres.pg-staging
priority = 100
condition = <<-CEL
sql.verb in [
'drop', 'truncate', 'alter', 'grant', 'revoke',
'create', 'comment', 'do', 'vacuum',
]
CEL
verdict = "deny"
reason = "Schema / privilege changes must land via migration PR"
}
rule "pg-banned-functions" {
endpoint = postgres.pg-staging
priority = 100
condition = <<-CEL
sets.intersects(sql.functions, [
'pg_read_file', 'pg_read_binary_file', 'lo_get',
])
|| sql.functions.exists(f, f.startsWith('dblink_'))
CEL
verdict = "deny"
reason = "Filesystem-reaching functions are not allowed"
}
rule "pg-small-mutations" {
endpoint = postgres.pg-staging
condition = "sql.verb in ['insert', 'update', 'delete', 'merge', 'notify']"
approve = [human_approver.db-review]
reason = "Postgres mutations require human approval"
}
rule "pg-secret-columns-check" {
endpoint = postgres.pg-staging
priority = 100
condition = <<-CEL
sql.verb == 'select'
&& sets.intersects(sql.tables, [
'github_identities',
'tokens',
'domain_certificates',
'env_vars',
'users',
])
CEL
approve = [llm_approver.pg-secret-columns-judge]
}
rule "pg-reads" {
endpoint = postgres.pg-staging
condition = "sql.verb in ['select', 'show', 'explain', 'use', 'describe']"
verdict = "allow"
}
rule "pg-default" {
endpoint = postgres.pg-staging
priority = -100
verdict = "deny"
reason = "Unknown SQL verb — explicit allow rule required"
}
The secret-columns rule intentionally gates by table first. The LLM
policy decides whether the specific projection returns secret data.
That avoids blocking useful metadata reads while still catching SELECT * and JSON/aggregate projections that would expose secret values.
# SQL: ClickHouse read-only telemetry
ClickHouse can use the same sql.* family. This rule set makes a
telemetry endpoint read-only and denies every other query shape.
credential "clickhouse_credential" "ch-telemetry" {
user = "agent_readonly"
}
endpoint "clickhouse_native" "clickhouse-o11y" {
hosts = ["clickhouse-o11y.example.com"]
tls = true
credential = clickhouse_credential.ch-telemetry
}
rule "clickhouse-allow-read" {
endpoint = clickhouse_native.clickhouse-o11y
condition = "sql.verb in ['select', 'show', 'describe', 'explain', 'use', 'exists']"
verdict = "allow"
}
rule "clickhouse-default" {
endpoint = clickhouse_native.clickhouse-o11y
priority = -100
verdict = "deny"
reason = "ClickHouse queries are denied unless explicitly allowed"
}