View as markdown

# 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 familyhttp, 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):

VariableTypeDescription
http.methodstringHTTP verb. Lowercased at activation time; literal 'POST' in rule source is normalized to 'post' at compile time so either case works.
http.pathstringRequest path (no query string)
http.querymap<string, list<string>>Query parameters (multi-valued)
http.headersmap<string, list<string>>Request headers (multi-valued)
http.bodystringRaw request body
http.body_jsondynParsed 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"
}
VariableTypeDescription
sql.verbstringFirst verb of the statement (lower-case: "select", …)
sql.tableslist<string>Tables referenced by the statement
sql.functionslist<string>Functions called by the statement
sql.statementstringThe full lower-cased statement text
sql.databasestringAgent-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"
}
VariableTypeDescription
k8s.verbstringHTTP-derived verb ("list", "get", "create", …)
k8s.resourcestring<resource> or <resource>/<sub> for subresources
k8s.namespacestringKubernetes namespace
k8s.namestringResource name
k8s.paramsmap<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
}
FieldRequired?Notes
endpoint / endpointsexactly oneBare-name refs to declared endpoints. All endpoints must share one protocol family.
priorityoptional (default 0)Higher fires first. Negative for catch-alls (-100 is the convention).
credentialoptionalBare-name ref. The runtime treats it as an extra predicate evaluated before the CEL condition: the request must have been dispatched against this credential.
conditionoptionalA CEL string evaluated against the family's variable set. Absent or empty matches every request the endpoint sees.
verdictone of verdict / approve"allow" or "deny".
approveone of verdict / approveList of approver bare names. Approvers run in order; all must allow for the request to proceed.
reasonoptionalSurfaced to the agent on deny / approver-deny, and shown on the dashboard.
disabledoptionalKeeps 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:

# Case sensitivity, by variable

VariableCase sensitivity
http.methodlower-case (rule-source literals normalized at compile time)
http.path, http.query, http.headers, http.bodyas on the wire
sql.verblower-case (normalized)
sql.tables, sql.functionslower-case (extracted from a lower-cased copy of the statement)
sql.statementas on the wire (raw text, no case folding)
sql.databaseas on the wire (StartupMessage / Hello / HTTP query+header)
k8s.verblower-case (normalized)
k8s.resource, k8s.namespace, k8s.name, k8s.paramsas 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:

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 starting configuration:

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.

EndpointInspected sliceCapTruncatable facet fields
httprequest body on POST / PUT / PATCH1 MiBhttp.body, http.body_json
kubernetesrequest body on POST / PUT / PATCH1 MiB(none — every k8s.* facet is derived from the URL and method)
clickhouse_httpsrequest body on POST / PUT / PATCH1 MiBsql.verb, sql.tables, sql.functions, sql.statement
postgresQuery (Q) and Parse (P) frame1 MiBsql.verb, sql.tables, sql.functions, sql.statement
clickhouse_nativeQuery packet body1 MiBsql.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:

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:

# 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"
}