Skip to content

Latest commit

 

History

History
1297 lines (999 loc) · 75.5 KB

File metadata and controls

1297 lines (999 loc) · 75.5 KB

SBproxy scripting reference: CEL, Rego, Lua, JavaScript, and WASM

Last modified: 2026-08-16

SBproxy includes five scripting engines for custom logic: CEL (Common Expression Language), Rego (via Regorus), Lua, JavaScript, and WASM. All run in sandboxed environments with access to request context.

Engine Implementation Best for
CEL cel-rust (the cel crate), with custom SBproxy functions Policy gates, routing keys, response header rules
Rego Regorus (Microsoft's Rust interpreter), in process Policy gates you already have written for OPA
Lua mlua running the Luau runtime, sandboxed Header modifiers, JSON body rewriting, WAF custom rules
JavaScript rquickjs (QuickJS), sandboxed with JSON helpers JS-native body transforms and response modifiers
WASM wasmtime running WASI preview-1 modules, no filesystem or network Polyglot body transforms, untrusted code with strong isolation

Reach for CEL for one-liner expressions that evaluate in microseconds. Reach for Lua, JavaScript, or WASM when you need variables, loops, helper functions, or multi-step logic.


1. Overview

Engine Execution Isolation
CEL Non-Turing-complete expression, compiled once at config load, evaluated per call No loops, no side effects, no I/O
Lua Interpreted, fresh sandboxed VM per invocation Globals set by one call never leak into the next
JavaScript QuickJS interpreter, fresh engine per invocation Dangerous globals removed, CPU/memory/stack caps
WASM Compiled to native via Wasmtime once at config load Fresh Store per request; module state never leaks

Lua and JavaScript deliberately build a fresh interpreter state for every invocation so one request's script can never observe another's globals. WASM modules compile once and instantiate per request.

CEL expressions that come from sb.yml are parsed once, while the config compiles, and the request path only evaluates them. That is what makes a CEL syntax error a config error: the proxy refuses to boot on one, and a hot reload carrying one is rejected with the previously active config still serving. It also means a config change is the only thing that can ever reparse an expression. Every CEL surface in the table below works this way.


2. Where scripts are used

Config field Engine Contract
policies[] type: expression, field expression CEL Returns bool; false denies the request with deny_status / deny_message
policies[] type: assertion, field expression CEL Returns bool at response time; a false assertion is logged and never blocks traffic
policies[] type: rate_limiting, field key CEL Returns the rate-limit bucket key (e.g. jwt.claims.tenant_id)
policies[] type: waf, field persistent_block.key CEL Returns the persistent-block tracking key when track_by: cel
observability.log.custom_fields[] with engine: cel CEL Returns the value of one operator-defined access-log field
request_modifiers[].lua_script Lua Defines modify_request(req, ctx); returned set_headers are applied to the upstream request
request_modifiers[].js_script JavaScript Defines modify_request(req, ctx); returned set_headers are applied to the upstream request
response_modifiers[].lua_script Lua Defines modify_response(resp, ctx); returned set_headers are applied to the response
response_modifiers[].js_script JavaScript Defines modify_response(resp, ctx); returned set_headers are applied to the response
transforms[] type: lua_json, field script Lua Defines modify_json(data, ctx); return value replaces the JSON response body
transforms[] type: javascript, field script JavaScript Defines transform(body, ctx) over the raw body string
transforms[] type: js_json, field script JavaScript Defines modify_json(data, ctx) over the parsed JSON body
transforms[] type: cel, field headers CEL Sets, appends, and removes response headers from CEL
transforms[] type: wasm, field module_path WASM Body on stdin, transformed body on stdout
policies[] type: rego, fields module + query (+ optional data) Rego The queried rule returns bool; false or any fault denies with deny_status / deny_message; data is a JSON object the rule reads as data.<key>
forward_rules[].rules[].when CEL Boolean predicate over the arriving request; an evaluation error means the rule does not match
observability.log.custom_fields[] with engine: lua or engine: js Lua or JavaScript Returns the value of one operator-defined access-log field
policies[] type: waf custom rules Lua or JavaScript Rule script defines match(request); true fires the rule
origins.<host>.response_cache.key_event, field source Lua or JavaScript Returns {vary, skip_lookup, reason} before the cache lookup; adds dimensions to the cache key
origins.<host>.response_cache.admit_event, field source Lua or JavaScript Returns {store, ttl_secs, reason} once the response body is complete; decides whether it is stored
action.ai_policy.expression (in ai_proxy) CEL Returns typed action tokens over the ai.* namespace; see ai-policy-cel.md
extensions bundle hooks attached as action, policies[], or transforms[] JavaScript, load-time TypeScript, or envelope WASM Uses a typed, versioned JSON envelope and the hook's type name
origins.<host>.filters[] Proxy-Wasm Runs an ordered Proxy-Wasm ABI 0.2.1 HTTP filter chain
Extension AI and payment hooks JavaScript, envelope WASM, or Proxy-Wasm for AI streaming Receives provider-neutral, credential-free events through versioned contracts

Two AI-gateway surfaces are deliberately not free-form scripting: the ai_policy block is a single CEL expression over gateway-computed signals (ai-policy-cel.md), and guardrails are typed guardrails: input: / output: blocks (injection, pii, jailbreak, toxicity, schema, ...) documented in ai-gateway.md.

Forward rules match with declarative matchers first (method, path, header, query, body) and may add a CEL when: predicate for the conditions those cannot express. See section 3.5 for the shapes and 3.2 for what a when: can read.


3. CEL expressions

CEL is a non-Turing-complete expression language. No loops, no side effects, no I/O. What it does have is fast, safe evaluation of conditions over the request context.

3.1 Context variables

The CEL context is built per request. Every binding below is available to expression policies, which have the widest context of the seven places a config accepts CEL. The others see less, and 3.2 lists exactly what.

Naming a binding your site does not populate is refused when the config loads, so a typo or a copied expression fails at boot with a message naming the site, the binding, and what is available there. Before v1.12 it compiled and then missed at evaluation, which on a rate-limit key: meant every request quietly sharing one __cel_key_error__ bucket.

request - incoming HTTP request

Field Type Description
request.method string HTTP method (GET, POST, etc.)
request.path string URL path
request.host string Hostname the request was routed by
request.headers map Request headers, keys lowercase with hyphens preserved
request.query string Raw query string (empty string when absent)
request.time int Wall clock at context build, Unix epoch seconds
request.unix_nanos int Same instant in epoch nanoseconds
request.agent_id string Resolved agent identifier (human, anonymous, unknown, or a catalog id like openai-gptbot)
request.agent_class string Alias of agent_id: the catalog id is the class
request.agent_vendor string Operator display name (OpenAI, Google, ...)
request.agent_purpose string Operator-stated purpose (training, search, assistant, ...)
request.agent_id_source string Which resolver signal matched (bot_auth, rdns, user_agent, anonymous_bot_auth, fallback)
request.agent_rdns_hostname string Forward-confirmed reverse-DNS hostname when the rDNS path matched
request.trust_tier string Conservative identity tier: suspicious, strong, named, or anonymous
request.aipref.train bool Parsed aipref: header, training axis (default true)
request.aipref.search bool Search axis (default true)
request.aipref.ai_input bool Inference-input axis (default true)
request.tls.ja3 / request.tls.ja4 / request.tls.ja4h string TLS fingerprints, "" when unavailable
request.tls.trustworthy bool Whether the fingerprint reflects the actual client
request.headless_signal.detected bool Whether the JA4 headless-browser detector matched
request.headless_signal.library string Library label (puppeteer, playwright, ...) or ""
request.headless_signal.confidence double Detector confidence in [0.0, 1.0]

Header normalization: header keys are lowercased only; hyphens are preserved. Always use bracket notation: request.headers["content-type"], not request.headers["Content-Type"] or request.headers.content_type.

request.kya.* (Know-Your-Agent verifier verdict) and request.ml_classification.* (ML agent classifier verdict) are available when their respective subsystems run.

The trust tier is computed once after identity enrichment and authentication. An observed denial wins over positive evidence; verified Web Bot Auth, CAP, KYA, or another signed agent verdict is strong; a sufficiently confident unsigned rule-pack identity is named; and missing evidence is anonymous. For example:

request.trust_tier == "strong" || request.trust_tier == "named"

connection - peer information

Field Type Description
connection.remote_ip string Client IP address, when known

jwt - decoded Authorization Bearer claims

Field Type Description
jwt.claims map Claims from Authorization: Bearer <jwt>, decoded but not signature-verified. Empty map when no header, no Bearer prefix, fewer than three segments, or non-object payload.

jwt.claims is for keying and routing decisions (rate-limit buckets, route gates). It is not an authentication boundary. Signature verification stays with the jwt auth provider configured under authentication:. A common pattern: gate the route with authentication: jwt, then key the rate limiter on jwt.claims.tenant_id using the same token.

# Rate-limit by tenant: each tenant_id gets its own bucket.
key: 'jwt.claims.tenant_id'

# Composite key: per-user inside per-tenant.
key: 'jwt.claims.tenant_id + ":" + jwt.claims.sub'

a JWT-bearing request rate-limited per tenant_id claim, each tenant getting its own token bucket

When the claim expression comes back empty the limiter falls back to client IP (config).

agent - resolved agent class

A top-level alias namespace for the request.agent_* fields, for cleaner expressions.

Field Type Description
agent.id string Resolved agent identifier
agent.class string Alias of agent.id
agent.vendor string Operator display name
agent.purpose string Operator-stated purpose
agent.source string Resolver signal that matched
agent.rdns_hostname string rDNS hostname when the rDNS path matched

envelope - capture envelope dimensions

Field Type Description
envelope.user_id string Resolved user identifier
envelope.user_id_source string Where user_id came from (header, jwt, forward_auth)
envelope.session_id string Session identifier
envelope.parent_session_id string Caller-supplied parent session
envelope.workspace_id string Tenant scope
envelope.properties map Custom properties captured at request entry

principal - unified caller identity

Field Type Description
principal.tenant_id string Tenant the request resolved to
principal.sub string Subject identifier (JWT sub, virtual-key name, basic-auth username)
principal.source string Provider slug (bearer, api_key, virtual_key, ...)
principal.virtual_key map { name, allowed_providers: [...] }, empty fields when no virtual key matched
principal.attrs.project string Attribution: project
principal.attrs.user string Attribution: user
principal.attrs.team string Attribution: team
principal.attrs.tags list Operator-supplied tags
principal.attrs.metadata map Metadata fan-out
principal.attrs.roles list Roles claimed by the principal
principal.claims map Verbatim claims map when JWT or OIDC auth stamped them

features - per-request feature flags

Field Type Description
features.debug bool Built-in debug flag
features.trace bool Built-in trace flag
features["no-cache"] bool Built-in no-cache flag (bracket access: hyphens are not valid CEL identifiers)
features.any_set bool True when any flag, built-in or extra, is set
features["<key>"] string Free-form k=v flag entries; unset keys render as ""

response - response data (response-time evaluation only)

Field Type Description
response.status int HTTP status code
response.headers map Response headers, lowercase keys
response.body_size int Response body size in bytes, when known

The response namespace is available where CEL runs at response time: assertion policies and the cel transform. The cel transform additionally binds response.body (the body as a UTF-8 string).

Within a populated namespace, missing fields render as zero values ("", 0, false, empty map), so expressions like size(request.agent_id) > 0 work without probing for presence first. A namespace whose subsystem never ran for the request (for example request.tls on a plain HTTP listener) may be absent entirely; guard those accesses or accept the fail-closed deny.

3.2 What each config site offers

Seven places in a config take a CEL expression, and they do not all see the same context. A transform: cel runs after the headless detector, so it gets request.headless_signal; a policy: expression runs before the response exists, so it has no response.

Binding policy: expression policy: assertion transform: cel rate_limit.key custom_log field waf track_by: cel forward rule when
request.method, .path, .host, .query, .headers yes yes no yes yes yes yes
request.time, .unix_nanos yes yes no yes no yes yes
connection.remote_ip yes yes no yes no yes yes
jwt.claims yes yes no yes no yes yes
request.trust_tier yes yes no no no no no
request.tls yes no yes no no no no
request.agent_class and the other agent_* scalars yes no yes no no no no
request.agent (detector map: .score, .headless_score, ...) yes no no no no no no
agent yes no yes no no no no
request.aipref yes no no no no no no
request.kya yes no no no no no no
request.ml_classification yes no no no no no no
principal yes no no no no no no
request.headless_signal no no yes no no no no
response no yes yes no response.status only no no
features yes no no yes no yes no
envelope no no no yes no yes no
request.key_id no no no yes no yes no
tenant_id, provider, model, tokens_in, tokens_out, client_ip, attribution no no no no yes no no

Three columns are easy to misread.

The transform: cel column has no request bindings at all, which surprises people. A response transform builds its request half from placeholders rather than from the request, so request.method there would always read "GET" no matter what the client sent. Rather than hand you a binding that quietly lies, the config refuses it. If you need to branch a response header on the request, decide it in a policy: expression and carry the result forward.

The custom_log column looks eccentric because it is. That site builds its own context rather than sharing the request builder, which is why it is the only one with attribution and token counts, and the only one whose request has no time. Treat it as its own vocabulary.

The waf column is identical to rate_limit.key because both run through the same evaluator. If you are keying a persistent block, write it as you would a rate-limit key.

The forward-rule column is the narrowest, and deliberately so. A forward rule matches during routing, before authentication, identity enrichment, the TLS fingerprint pass, and the classifiers have run, so none of what those produce exists yet. It sees the request as it arrived.

A when: is ANDed with the structured matchers in the same entry and evaluated last, so a rule that fails a cheap path check never pays for it. Use it for what the structured fields cannot say, which is OR, negation, and comparisons across two parts of the request:

forward_rules:
  - rules:
      - header:
          name: x-tenant
          value: acme
        when: '!request.path.startsWith("/internal/")'
    origin:
      action:
        type: proxy
        url: https://acme.test.sbproxy.dev

That one is not expressible without it. Entries in a rules: list OR, and matchers inside one entry AND, so a plain OR across two paths is already two entries and does not need CEL. What has no structured form is the negation above, and comparisons that draw on two different parts of the request at once.

A predicate that fails to evaluate does not match, and the rule is skipped. Routing past a gate an operator wrote would be the worse failure.

One related surface is not in the table. ai_policy.expression evaluates over a single ai namespace and is documented in ai-policy-cel.md; it does not share this context, and its expressions are checked at config load against that one-namespace vocabulary rather than this table.

A binding marked no is refused when the config loads, whether you write it as request.trust_tier or as request["trust_tier"]. The message names the site and lists what that site does provide, so the fix is usually visible without opening this page:

origin `api`: invalid policy config

Caused by:
    policy `expression`: expression "request.headless_signal.detected" references
    request.headless_signal, which policy `expression` does not provide. Available
    here: agent, connection.remote_ip, features, jwt.claims, principal,
    request.agent, request.agent_class, ..., request.unix_nanos

3.3 Built-in functions

CEL includes the standard operators (+, -, *, /, %, in, ==, !=, <, >, <=, >=, &&, ||, !) and the cel crate's stock helpers such as contains, startsWith, endsWith, and size. SBproxy registers these additional functions on every evaluation context:

Function Returns Description
ip_in_cidr(ip, cidr) bool True if ip falls within cidr (e.g. "10.0.0.0/8"); false on unparseable input
uuid_v4() string Random UUID v4
now() string Current UTC time as an RFC 3339 string
sha256(s) string SHA-256 hex digest of s
base64_encode(s) string Standard base64 encoding
base64_decode(s) string Standard base64 decoding; errors on invalid input
regex_match(s, pattern) bool True if s matches pattern. Patterns over 1024 bytes or that exceed the compile size limit are rejected (returns false, logs a warning)
s.toLowerCase() string Lowercase
s.toUpperCase() string Uppercase
s.trim() string Trim leading and trailing whitespace
s.split(sep) list Split s on sep
flag_enabled(name, key) bool Resolve a feature flag against the live flag store; unknown flags evaluate false
tls_fingerprint_matches(ja4, agent_class_id) bool True when ja4 is a known fingerprint for the cataloged agent class, or when the catalog has no entry for the class (conservative)

flag_enabled reads the process-wide set declared by the top-level flags: block. Its second argument is the stable bucketing key; use a user, tenant, or subject identifier rather than a random request ID. Successful hot reloads replace the full flag set atomically, and an absent flags: block clears it. Unknown flags evaluate to false. See Edge feature flags for the rule grammar.

3.4 CEL policy examples

The scripted request gate is the expression policy. It takes one CEL expression; false (or an evaluation error) denies the request with deny_status (default 403) and deny_message.

The expression is compiled once, when the config compiles. An expression that does not parse rejects the whole config: at boot the proxy refuses to start, and on a hot reload the candidate config is rejected and the previously active config keeps serving traffic. The error names the origin, the policy, and the bad expression. At request time only evaluation can fail, and an evaluation error (a missing map key, a non-boolean result) denies the request: the expression could not prove the request is allowed.

Gate a route on a header value

origins:
  "cel.local":
    action:
      type: proxy
      url: https://test.sbproxy.dev
    policies:
      - type: expression
        expression: 'request.headers["x-tenant"] == "acme"'
        deny_status: 403
        deny_message: "tenant not allowed"

API traffic only, specific methods

policies:
  - type: expression
    expression: 'request.path.startsWith("/api/") && request.method in ["GET", "POST"]'
    deny_message: "only GET/POST under /api/"

Requests from a CIDR range

policies:
  - type: expression
    expression: 'ip_in_cidr(connection.remote_ip, "10.0.0.0/8")'
    deny_status: 403
    deny_message: "internal network only"

JWT-claim role gate

policies:
  - type: expression
    expression: '"admin" in principal.attrs.roles || jwt.claims.role == "admin"'
    deny_status: 403
    deny_message: "admin role required"

Block traffic that opted out of training

policies:
  - type: expression
    expression: 'request.aipref.train || request.headers["x-research-license"] != ""'
    deny_message: "Training use requires aipref: train=yes or a research license header."

Agent-class gate with TLS fingerprint check

policies:
  - type: expression
    expression: >
      request.agent_id != "openai-gptbot" ||
      tls_fingerprint_matches(request.tls.ja4, request.agent_id)
    deny_status: 403
    deny_message: "fingerprint does not match claimed agent"

Rate limiting keyed on a claim

policies:
  - type: rate_limiting
    requests_per_minute: 100
    burst: 20
    key: 'jwt.claims.tenant_id'

The full working config is in examples/ratelimit-by-claim/.

The key: expression compiles with the config, so a syntax error in it refuses the config the same way an expression policy does. At request time, an expression that evaluates to null or an empty string means "no key for this request" and the request falls back to the default client key (client IP, or the hostname when no client IP is known). An expression that fails to evaluate is different: the request is bucketed under a __cel_key_error__: prefix on the default client key, and the failure is logged. Rate limiting stays on either way, and error traffic never shares a bucket with correctly keyed traffic.

Response assertions

The assertion policy (alias response_assertion) evaluates CEL against the response and logs the verdict. It is observational: a false assertion never changes the response, and never blocks traffic.

policies:
  - type: assertion
    name: no-5xx
    expression: 'response.status < 500'

The expression compiles with the config, so a syntax error refuses the config. This matters more here than the log-only framing suggests: before, a mis-typed assertion parsed at response time, failed, and was skipped, so the check the operator wrote never ran and nothing said so.

At response time an evaluation error (a key the response did not carry, a result that is not a boolean) is logged and recorded as a pass. Nothing branches on the verdict, so recording a failure would put a line in the log claiming an assertion failed when it never ran.

The Go-compatible shape is accepted too, and compiles the same way:

policies:
  - type: response_assertion
    assertions:
      - name: no-5xx
        cel_expr: 'response.status < 500'
        action: pass

3.5 Forward-rule matchers

Forward rules dispatch to inline child origins with declarative matchers, evaluated in order with first match winning. Each entry in a rule's rules: list may carry a method, path, header, query, and body matcher; matchers present in one entry are ANDed, entries in the list are ORed.

An entry may also carry a CEL when:, ANDed with the rest and evaluated last. Reach for it when a condition needs OR, negation, or a comparison across two parts of the request, which the structured matchers cannot express no matter how many of them are added. Its bindings are in 3.2, and they are the narrowest of any surface because routing runs before the rest of the pipeline.

origins:
  "gateway.local":
    action:
      type: proxy
      url: https://test.sbproxy.dev
    forward_rules:
      # Path prefix.
      - rules:
          - path:
              prefix: /api/
        origin:
          action:
            type: proxy
            url: https://api-backend.internal

      # Exact path.
      - rules:
          - path:
              exact: /healthz
        origin:
          action:
            type: static
            status: 200
            content_type: text/plain
            body: ok

      # Template with named segments and a per-segment constraint.
      - rules:
          - path:
              template: /users/{id:[0-9]+}/posts/{post_id}
        origin:
          action:
            type: proxy
            url: https://posts-backend.internal

      # Whole-path regex escape hatch.
      - rules:
          - path:
              regex: '^/v[0-9]+/reports/.*$'
        origin:
          action:
            type: proxy
            url: https://reports-backend.internal

      # Header AND query in one entry.
      - rules:
          - header:
              name: X-Beta-User
              value: "true"
            query:
              name: env
              value: staging
        origin:
          action:
            type: static
            status: 200
            content_type: application/json
            body: '{"beta": true}'

The shorthand match: /api/ on an entry is equivalent to path: { prefix: /api/ }. Header matchers take name plus either value (exact) or prefix; header name lookup is case-insensitive, value comparison is case-sensitive. Query matchers take name and an optional exact value; with no value, parameter presence is enough. Template captures surface as path_params on the request context.

There is no lua: matcher inside forward rules, and the CEL one is when: above. For a condition needing a binding routing does not have, such as a trust tier or a classifier verdict, gate with an expression policy instead: those run after the passes that produce it.

3.6 The cel response transform

The cel transform is the CEL surface on the response path. It sets, appends, or removes response headers via per-header rules with value_expr CEL expressions.

origins:
  "api.local":
    action:
      type: proxy
      url: https://test.sbproxy.dev
    transforms:
      - type: cel
        headers:
          - { op: set, name: x-served-by, value_expr: '"sbproxy"' }
          - { op: set, name: x-upstream-status, value_expr: 'string(response.status)' }
          - { op: remove, name: x-internal-trace }

Each value_expr sees response.body, response.status, response.headers, and the request.* namespace. A string result is used verbatim; ints, floats, and bools render as strings; maps and lists are JSON-serialized; null skips the rule. Set-Cookie is on a deny-list: a CEL header rule cannot set it.

Every value_expr is compiled when the config compiles. A syntax error refuses the config, naming the origin and the header the expression belongs to. Responses then only evaluate.

CEL decides, it does not produce. This transform cannot write a response body, and two removed keys are refused at config compile rather than ignored:

Removed key Why Reach for instead
on_request: Compiled at config load and never evaluated. Every transform here is response-side: the dispatch signature is (body, content_type) and it runs off the response body buffer, so there was no request phase for the expression to run in. An expression policy to gate the request, a rate-limit or WAF key: expression to key on it, or a forward rule to route on it.
on_response: (alias expression:) Replaced the entire response body with whatever scalar the expression evaluated to. No partial edit, no structure-aware change, no streaming. That is producing output, which is a different job from deciding. A javascript, lua_json, or WASM transform. Each parses the body, edits part of it, and re-emits.

At response time the posture is deliberately forgiving, because the response is already on its way out: a header rule whose expression fails is skipped, the rest of the chain still runs, and the failure is logged.


3a. Rego policies

policy: rego evaluates a Rego module against the same request context a policy: expression sees. It exists for one reason: some teams already have Rego, and rewriting a working policy set is worse than running it. If you are writing a new policy from scratch, prefer expression; the reasons are below and they are not stylistic.

policies:
  - type: rego
    module: |
      package sbproxy

      default allow := false

      allow if {
        input.request.trust_tier == "strong"
      }

      allow if {
        input.request.method == "GET"
        startswith(input.request.path, "/public/")
      }
    # Optional. Defaults shown.
    query: data.sbproxy.allow
    deny_status: 403
    deny_message: forbidden by policy
    budget_ms: 50

What input contains

The exact binding set of policy: expression in the table above, converted to JSON. request.trust_tier in CEL is input.request.trust_tier in Rego; both engines read the same assembled context, so the vocabulary cannot drift between them. A decision is portable in both directions by translating syntax alone, with two exceptions below.

input.jwt.claims deserves the same warning it carries on the CEL side, and it matters more here because Rego is what people reach for to write authorization: the claims are decoded, not verified. input.jwt.claims.role == "admin" trusts whatever the client sent. Signature verification belongs to the jwt auth provider under authentication:; gate the route there first, then use the claims for authorization.

Base data: the table the rule reads

Rego splits what a policy decides from the data it decides against: the rule is input, the reference data is data. policy: rego carries that split with an optional data field, a JSON object the rule reads as data.<key>:

policies:
  - type: rego
    module: |
      package sbproxy
      default allow := false
      allow if { input.request.method == data.allowed_methods[_] }
    data:
      allowed_methods: ["GET", "HEAD"]

The point is that the allowlist, role table, or routing map lives in its own config value, separate from the module. An operator edits data.allowed_methods without reading a line of Rego, and the policy logic never changes when the table does. data must be a JSON object (the rule indexes into it by key), and it is capped at one megabyte serialized: base data is a config-embedded table, not a bulk dataset, and a document that large belongs behind a data source rather than inline. Because data is ordinary config, editing it is a config change like any other, applied on the next reload.

One shape is refused at config load: a data document that defines a value at the exact path the query names. Rego resolves the base document over a rule's own value at the same path, so a data of {sbproxy: {allow: true}} under a data.sbproxy.allow query would silently override the allow rule and make every request identical while the rule body still runs. Keep base data under a key the queried rule does not produce; a sibling like data.sbproxy.roles next to an allow rule is fine.

The two things Rego does not inherit

Typos are not caught at config load. A CEL expression naming a binding its surface does not provide is refused when the config loads. Rego cannot offer that: input.request.trust_teir is not an error, it is undefined, which is a value the language is designed to reason about. A misspelled binding is a rule that never fires, discovered from traffic behavior rather than from an error message. This is the strongest reason to prefer expression when either engine would do.

The SBproxy helper functions do not exist in Rego. flag_enabled() and tls_fingerprint_matches() have no Rego equivalent; a policy needing either belongs in expression. The generic helpers have standard Rego analogs: ip_in_cidr is net.cidr_contains, sha256 is crypto.sha256, regex_match is regex.match.

Failure posture

Everything fails closed, at the earliest point it can be detected:

Fault When it is caught What happens
Module does not parse config load config refused, boot and reload both name the error
Module parses but is semantically invalid (unsafe variable, query naming no rule) config load config refused; the engine runs one trial evaluation so deferred analysis cannot push an authoring mistake to request time
Rule errors, returns a non-boolean, or exceeds budget_ms at request time per request request denied with deny_status, one warning logged

budget_ms bounds one evaluation's wall clock and defaults to 50ms, matching the extension-bundle sandbox. It cannot be zero.

One OPA divergence worth knowing

Builtin errors are strict here. Upstream OPA treats a builtin error as undefined and moves on; Regorus propagates it, and this surface turns it into a denial. A policy that leans on that forgiveness upstream, for example calling net.cidr_contains on a header that is sometimes not a CIDR, works on OPA and denies here. Guard the input first, or accept the deny.


4. Lua scripting

Lua gives you a full scripting language: variables, conditionals, helper functions, and string handling. The proxy uses the Luau runtime via mlua. Every invocation runs in a fresh sandboxed VM under a configurable wall-clock and memory budget; see §4.6 for the operator knobs.

4.1 Function contract

Lua modifier scripts define a named function; the proxy calls it with the request or response data plus a context table.

-- Request modifier: define modify_request(req, ctx), return a table.
function modify_request(req, ctx)
  return {
    set_headers = {
      ["X-Original-Path"] = req.path,
      ["X-Method"] = req.method
    }
  }
end
-- Response modifier: define modify_response(resp, ctx), return a table.
function modify_response(resp, ctx)
  if resp.status_code >= 500 then
    return { set_headers = { ["X-Upstream-Health"] = "degraded" } }
  end
  return { set_headers = { ["X-Upstream-Health"] = "ok" } }
end

On both paths, the only field the proxy applies from the returned table is set_headers: a map of header name to string value, inserted onto the upstream request or the client response. Lua modifiers cannot change the path, method, query, status, or body; use the typed modifier fields for those (section 7).

A legacy request script that defines match_request(req, ctx) and calls req:set_header(name, value) also works: the proxy falls back to it when modify_request is not defined.

4.2 Context tables

req (request modifiers)

req.method    -- "GET", "POST", ...
req.path      -- "/api/users"
req.headers   -- table, keys lowercase
req.host      -- the origin hostname that routed the request
req.tls.ja3   -- TLS fingerprints, empty strings on plain HTTP
req.tls.ja4
req.tls.ja4h
req.tls.trustworthy  -- boolean, false when no fingerprint was captured

Anything else you need (client IP, agent class) has to arrive as a header or be handled in CEL, where the wider namespace lives. Caller identity is on ctx.principal, below.

resp (response modifiers)

resp.status_code  -- numeric HTTP status
resp.headers      -- response headers table

ctx (second argument)

Request modifiers, response modifiers, and the Lua / JavaScript JSON transforms all receive the same context table. It carries the parsed aipref signal, the TLS fingerprint, and the unified caller identity:

ctx.request.aipref.train     -- boolean, default true
ctx.request.aipref.search    -- boolean, default true
ctx.request.aipref.ai_input  -- boolean, default true

ctx.request.tls.ja4          -- same fields as req.tls above

ctx.principal.tenant_id      -- tenant the request resolved to
ctx.principal.sub            -- subject id, "" for anonymous callers
ctx.principal.source         -- provider slug ("jwt", "virtual_key", ...)
ctx.principal.virtual_key.name
ctx.principal.virtual_key.allowed_providers  -- list
ctx.principal.attrs.project  -- attribution fields, "" when unset
ctx.principal.attrs.user
ctx.principal.attrs.team
ctx.principal.attrs.tags     -- list
ctx.principal.attrs.metadata -- map
ctx.principal.attrs.roles    -- list
ctx.principal.claims         -- verbatim JWT/OIDC claims map, {} otherwise

The principal shape is field-for-field the CEL principal.* namespace from section 3.1, so a policy written for CEL ports to Lua or JavaScript by swapping the dot paths. Empty and missing values render as empty strings, lists, and maps rather than being omitted, so a script can branch on ctx.principal.attrs.team without probing for presence first.

4.3 JSON helpers

Two global functions are registered in every Lua VM:

json_encode({name = "alice"})   -- '{"name":"alice"}'
json_decode('{"x":1}')          -- {x = 1}
-- json_decode raises an error on invalid input; wrap with pcall
-- when the input is untrusted.
local ok, t = pcall(json_decode, maybe_json)

These are the only host helpers. There is no logging, crypto, UUID, or time module in the Lua sandbox; if you need hashing, UUIDs, or timestamps, use CEL (sha256, uuid_v4, now) or do the work upstream.

4.4 JSON transformation

The lua_json transform parses a JSON response body, hands it to modify_json(data, ctx), and replaces the body with whatever the function returns.

origins:
  "api.local":
    action:
      type: proxy
      url: https://test.sbproxy.dev
    transforms:
      - type: lua_json
        script: |
          function modify_json(data, ctx)
            data.password = nil
            data.internal_id = nil
            data.processed = true
            return data
          end

A legacy format is also supported: a script with no modify_json function runs with the parsed body bound to a body global, and the script's return value replaces the body.

4.5 Lua examples

Classify the caller from the User-Agent

origins:
  "api.local":
    action:
      type: proxy
      url: https://test.sbproxy.dev
    request_modifiers:
      - lua_script: |
          function modify_request(req, ctx)
            local ua = req.headers["user-agent"] or ""
            local kind = "browser"
            if string.find(ua, "GPTBot") or string.find(ua, "ClaudeBot") then
              kind = "crawler"
            end
            return {
              set_headers = {
                ["X-Caller-Kind"] = kind,
                ["X-Original-Path"] = req.path
              }
            }
          end

Conditional header from a role header

request_modifiers:
  - lua_script: |
      function modify_request(req, ctx)
        local role = req.headers["x-role"] or ""
        local is_admin = "false"
        if role == "admin" then
          is_admin = "true"
        end
        return {
          set_headers = { ["X-Is-Admin"] = is_admin }
        }
      end

Tag responses by upstream status

response_modifiers:
  - lua_script: |
      function modify_response(resp, ctx)
        local bucket = "2xx"
        if resp.status_code >= 500 then
          bucket = "5xx"
        elseif resp.status_code >= 400 then
          bucket = "4xx"
        end
        return {
          set_headers = {
            ["X-Status-Bucket"] = bucket,
            ["X-Content-Type-Options"] = "nosniff"
          }
        }
      end

Stamp the aipref verdict onto the response

response_modifiers:
  - lua_script: |
      function modify_response(resp, ctx)
        local train = "yes"
        if ctx.request.aipref.train == false then
          train = "no"
        end
        return {
          set_headers = { ["X-AIPref-Train"] = train }
        }
      end

Compute a JSON field from two others

transforms:
  - type: lua_json
    script: |
      function modify_json(data, ctx)
        if data.first_name and data.last_name then
          data.full_name = data.first_name .. " " .. data.last_name
        end
        data.is_adult = (data.age or 0) >= 18
        return data
      end

For path rewriting, method overrides, query-string edits, and body replacement, use the typed modifier fields alongside (or instead of) a script; see section 7.

4.6 Sandbox limits

Every Lua invocation runs under a configurable sandbox. The defaults are tight enough to keep an adversarial script from stalling a worker; raise them if your scripts legitimately need more headroom, or tighten them further on sensitive deployments.

proxy:
  scripting:
    lua:
      sandbox:
        max_execution_ms: 100   # wall-clock budget per invocation
        max_memory_mb: 8        # cap on the Lua VM's allocator footprint
        allow_patterns: true    # expose string.find / string.match / string.gmatch
Field Default Notes
max_execution_ms 100 Wall-clock budget per invocation. Scripts that exceed it abort with a sandbox-timeout error and the request fails closed. Set 0 to disable the timer (not recommended).
max_memory_mb 8 Hard ceiling on the Lua VM's allocator footprint. Allocations past the cap fail the script rather than letting it grow the proxy's resident set.
allow_patterns true Whether to expose the Lua pattern API (string.find, string.match, string.gmatch). The pattern engine has known pathological inputs; flip to false if your scripts do not need pattern matching. The rest of string.* keeps working either way.

Limits apply to every Lua surface uniformly: request modifiers, response modifiers, JSON transforms, and WAF custom rules. Changes take effect on the next config reload (SIGHUP, admin reload, or filesystem watch) without restarting the process.


5. JavaScript scripting

JavaScript runs on QuickJS via rquickjs. Every invocation gets a sandboxed engine with eval removed and two global helpers registered: json_encode (alias of JSON.stringify) and json_decode (alias of JSON.parse).

Response modifiers define modify_response(resp, ctx) and, like Lua, only the returned set_headers map is applied:

response_modifiers:
  - js_script: |
      function modify_response(resp, ctx) {
        return {
          set_headers: {
            "X-Processed-By": "js",
            "X-Status": String(resp.status_code)
          }
        };
      }

Body transforms come in two shapes. type: javascript runs transform(body, ctx) over the raw body string (a non-string return value is JSON-serialized); type: js_json runs modify_json(data, ctx) over the parsed JSON body. Both accept an optional function_name to call a differently named entrypoint.

transforms:
  - type: javascript
    script: |
      function transform(body, ctx) {
        return body.toUpperCase();
      }
  - type: js_json
    script: |
      function modify_json(data, ctx) {
        data.processed = true;
        return data;
      }

The ctx argument carries the same context table as the Lua surfaces in section 4.2: ctx.request.aipref.* (each flag defaulting to true when the request has no valid aipref header), ctx.request.tls.* (JA3/JA4/JA4H fingerprints, empty strings on plain HTTP), and ctx.principal.* (the unified caller identity, mirroring the CEL principal.* namespace from section 3.1).

5.1 Sandbox limits

QuickJS always runs with a sandbox. The defaults keep an adversarial script from stalling a worker; raise them if your scripts legitimately need more headroom, or tighten them on sensitive deployments.

proxy:
  scripting:
    javascript:
      sandbox:
        budget_ms: 100    # wall-clock CPU budget per invocation
        memory_mb: 16     # heap cap for the QuickJS runtime
        stack_kb: 1024    # native stack cap
Field Default Notes
budget_ms 100 Wall-clock CPU budget per invocation. A script that overruns it is aborted by a watchdog with an uncatchable exception; the modifier or transform is skipped and the error is logged. This is the guard against while (true) {}.
memory_mb 16 Heap cap for the QuickJS runtime. An allocation past the cap fails the script rather than letting it grow the proxy's resident set.
stack_kb 1024 Native stack cap. Guards against deeply recursive scripts.

Limits apply to every JavaScript surface uniformly: response modifiers, body and JSON transforms, WAF custom rules, MCP adapters, and engine: js custom log fields. Changes take effect on the next config reload (SIGHUP, admin reload, or filesystem watch) without restarting the process, the same as the Lua block in §4.6.


6. WASM scripting

WASM modules run in wasmtime against the WASI preview-1 ABI. The host pipes the response body in on the module's stdin and captures whatever the module writes to stdout. There is no custom calling convention to learn; any wasm32-wasi binary that reads stdin and writes stdout works.

WASM is currently exposed as a body transform (type: wasm), not as a request/response modifier. Use it when you need to mutate the response body in a language that does not have a first-class engine here (Rust, TinyGo, AssemblyScript, Zig, etc.) or when you want stronger isolation than CEL or Lua provide.

Because the contract is raw bytes on stdin, WASM modules do not receive the ctx context the Lua and JavaScript surfaces get: there is no JSON envelope to carry principal or request.tls, and inventing one would break every deployed module that parses its body off stdin. If a module needs caller identity or fingerprint data, gate the transform with a CEL policy or stamp the needed value into a header with a Lua/JS modifier ahead of it.

origins:
  "wasm.local":
    action:
      type: static
      status_code: 200
      content_type: text/plain
      body: "hello from sbproxy"
    transforms:
      - type: wasm
        module_path: /etc/sbproxy/modules/uppercase.wasm
        timeout_ms: 500
        max_memory_pages: 256

Sandbox tunables:

Field Default Description
module_path required Filesystem path to a .wasm module compiled for wasm32-wasi. Resolved relative to the proxy's working directory.
module_bytes optional Inline bytes of a precompiled module. One of module_path or module_bytes must be set.
sha256 optional Lowercase hex SHA-256 digest the selected module bytes must match; a mismatch is refused before compilation.
timeout_ms 1000 Hard wall-clock cap per invocation. Enforced via wasmtime's epoch interruption.
max_memory_pages 256 Linear-memory cap in 64 KiB pages. 256 = 16 MiB.
max_fuel 1,000,000,000 Deterministic, instruction-granular cap on one invocation, complementing timeout_ms.

There is no filesystem access, no network access, no environment variables, and no clock skew the host can observe.

There is also no allowed_hosts:. The key used to be accepted here and was never enforced, which is the wrong shape for something that reads as a security boundary: modules get no sockets at all, so an allowlist had nothing to sit in front of. It is refused at config compile now, with an error saying so. If a host callout ever lands, the key comes back as an enforced one. Until then, keep the reaching on the proxy side: gate the origin with an expression policy, or route the callout through an origin the proxy controls.

The full authoring guide is in wasm-development.md, with hello-world Rust and TinyGo modules in examples/wasm/.


Cache decision events

response_cache is otherwise static. Two optional events let a script answer part of it per request: key_event decides what the cache key varies on, and admit_event decides whether a finished response is stored and for how long. Both sit under an origin's response_cache block and both accept lua and js, in the same source plus engine shape as custom_fields.

They are two events rather than one cache policy because of an ordering constraint. A key has to exist before anything can be looked up under it, so key_event runs on the request, before the lookup, with no response in scope. Whether a response is worth storing depends on its status and its size, neither of which exists at request time, so admit_event runs after the response body is complete.

CEL is not on the list, and the reason is the return type. Every CEL surface in section 2 answers with one scalar: a bool, a bucket key, a header value. These events answer with a document, so serving them from CEL would mean packing a document into a string and parsing it back out, which is how route_to:gpt-4o-mini became a mini-language. engine: cel is refused at config compile, naming lua and js instead. engine: wasm is refused on different grounds: the field takes inline source and a compiled module is not inline source, so a WASM hook attaches through an extension bundle.

origins:
  "api.local":
    action:
      type: proxy
      url: https://test.sbproxy.dev
    response_cache:
      enabled: true
      ttl_secs: 300
      cacheable_status: [200]
      key_event:
        engine: lua
        source: |
          -- Reports are per tenant and per plan tier. Everything else
          -- keeps the origin's static `vary:`.
          if string.find(ctx.request.path, "/v1/reports", 1, true) == 1 then
            return {
              vary = { "header:x-plan-tier", "header:x-region" },
              reason = "a report body differs per tenant and per plan tier",
            }
          end
          return {}
      admit_event:
        engine: js
        source: |
          (() => {
            if (ctx.response.body_bytes > 1048576) {
              return { store: false, reason: "too large to be worth a cache slot" };
            }
            if (ctx.response.headers["cache-control"] === "no-store") {
              return { store: false, reason: "upstream said no-store" };
            }
            return { store: true, ttl_secs: 60, reason: "report bodies go stale fast" };
          })()

What the script receives

Each event hands its input to the script as a ctx global, the same way engine: lua and engine: js custom log fields do. This is not the modifier context table from section 4.2: there is no principal, no aipref, and no TLS fingerprint here.

key_event sees the request:

ctx.request.method
ctx.request.path
ctx.request.query    -- "" when the request carries none
ctx.request.host
ctx.tenant           -- resolved tenant id, "" when there is none
ctx.origin           -- origin id

admit_event sees the finished response, plus enough of the request to tell one route from another:

ctx.response.status
ctx.response.body_bytes
ctx.response.headers  -- names lowercased; hop-by-hop headers dropped
ctx.request.path
ctx.request.host
ctx.tenant
ctx.origin

Request headers are deliberately not in either context. A key_event names the dimensions and the host resolves their values, so a script can vary on a header it cannot read. To branch on a header value, stamp a derived value into the path or route the traffic to its own origin.

What the script returns

Lua returns the document (return { ... }); JavaScript evaluates to it, so wrap anything with branches in an immediately invoked function as above. A bare object literal at the end of a JavaScript source is parsed as a block, not an object.

key_event:

Field Type Meaning
vary list of strings Dimensions folded into the cache key, added to the origin's static vary:. At most 16, each at most 128 bytes.
skip_lookup bool Go upstream for this request rather than reading the cache. The response stays eligible for storage.
reason string Free text explaining the plan, trimmed and truncated at 512 bytes. Carried with the decision; nothing branches on it.

admit_event:

Field Type Meaning
store bool Required. Whether this response is written to the cache.
ttl_secs int TTL for this entry, replacing the configured ttl_secs. Clamped to 30 days.
reason string Same free text, same bounds.

Declining is the cheap common case and means "the static config applies unchanged": return {} or return nil in Lua, an empty object or nothing at all in JavaScript. A key_event document with no vary and no skip_lookup declines too. admit_event is the one exception to everything being optional: any document that is not empty has to carry store, because there is no safe default for it. Guessing true caches a response the policy never approved, and guessing false switches the cache off without saying so.

Rules the events cannot bend

Dimension names are a closed set. Each name in vary is either query or a request header written header:<name>. Anything else is refused when the document is decoded. A name resolving to nothing would contribute the same empty value to every request, partition nothing, and merge every caller into one cache entry, so a typo has to fail loudly rather than quietly serve one customer's response to another. Names are trimmed, lowercased, deduplicated, and sorted, which keeps the same set in a different order producing the same key.

A key can only get narrower. The <workspace>:<hostname>:<method>:<path>:<query>: prefix is stamped by the host whatever the event returns, so a policy adds dimensions and can never widen a key. Worth being precise about what separates tenants: workspace is empty on every path today, so that separation comes from hostname plus the per-origin cache store, not from the workspace segment.

A faulted key_event bypasses the cache. If the engine faults, or the document cannot be decoded, the request gets no cache read and no cache write. Falling back to the static vary: alone would produce a coarser key rather than a narrower one, and the same key carries the write-back, so that response would be published to every other caller whose script also faulted. admit_event fails the other way, because nothing about the key changed: a fault there stores the response under the configured ttl_secs, which is what an origin without the event already does.

skip_lookup is not a refusal to cache. It sends this request upstream and leaves the response eligible for storage, which is what a caller asking for fresh data usually wants. To keep a response out of the cache, return store: false from admit_event.

admit_event runs downstream of cacheable_status. It only sees a response whose status already passed that gate, so it can decline a status the gate allows and cannot start caching one the gate excludes.

admit_event and stale_while_revalidate do not compose. The revalidation refresh runs in the background with no request context, so it cannot evaluate the event and would write back with the static ttl_secs, reverting both the override and any refusal. Setting both on one origin fails config compile until the refresh can carry the event's context.

Both events run under the sandboxes in §4.6 and §5.1, with a fresh VM per evaluation. Evaluations are counted on sbproxy_decision_event_total{event="cache.key"} and {event="cache.admit"}, and the two faults are counted differently on purpose: cache.admit fails open, so it records outcome="allow" plus sbproxy_decision_event_fail_open_total, while cache.key fails closed on the cache and records outcome="error", or outcome="timeout" when the script ran out of its CPU budget, with no fail-open counter. The field-level reference for the block is in configuration.md.


7. Modifier reference

Request and response modifiers are lists of typed entries. Each entry can combine the structural fields below with an optional script; entries apply in order.

Request modifier fields

Field Type Description
headers.set map Set headers, replacing existing values
headers.add map Append headers, preserving existing values
headers.remove list Remove headers by name (alias: delete)
url.path.replace map { old, new } substring replacement on the path
query.set map Set (overwrite) query parameters
query.add map Add query parameters, appending even when the key exists
query.remove list Remove query parameters by name (alias: delete)
method string Override the HTTP method (e.g. "POST")
body.replace string Replace the request body with this string
body.replace_json any Replace the request body with this JSON value
lua_script string Lua modify_request(req, ctx); returned set_headers applied
request_modifiers:
  - headers:
      set:
        X-Forwarded-Env: production
      remove:
        - X-Internal-Debug
    url:
      path:
        replace:
          old: /v1/
          new: /v2/
    query:
      add:
        source: proxy
      remove:
        - debug
    method: POST

Response modifier fields

Field Type Description
headers.set / headers.add / headers.remove map / map / list Same semantics as the request side
status.code int Override the response status code
status.text string Custom reason phrase for the HTTP/1.x status line; ignored on HTTP/2, which has no reason phrase on the wire
body.replace string Replace the response body with this string
body.replace_json any Replace the response body with this JSON value
lua_script string Lua modify_response(resp, ctx); returned set_headers applied
js_script string JavaScript modify_response(resp, ctx); returned set_headers applied
response_modifiers:
  - headers:
      set:
        X-Frame-Options: DENY
        Strict-Transport-Security: max-age=31536000
      remove:
        - X-Powered-By
        - Server
    status:
      code: 503
    body:
      replace_json:
        error: "Service temporarily unavailable"

For JSON body surgery on responses, prefer the JSON transforms: the typed json transform (set / remove / rename fields) for static edits, or lua_json / js_json for computed edits.


8. AI-gateway scripting pointers

The AI proxy action does not embed the general scripting engines. It has two dedicated surfaces:

  • ai_policy: a single sandboxed CEL expression over the ai.* namespace (surface, model, provider, principal tier, guardrail verdicts, budget state, token estimates) that returns typed action tokens (allow, block, redact, route_to:<model>, set_sink_tag:<tag>, audit:<priority>). See ai-policy-cel.md and examples/ai-policy-cel/.
  • Guardrails: typed guardrails: input: / output: blocks (injection, pii, jailbreak, toxicity, schema, ...), configured declaratively rather than as expressions. See ai-gateway.md and examples/ai-guardrails/.

9. Sandbox limits summary

CEL

  • Non-Turing-complete: no loops, no side effects, no I/O.
  • No access to secrets. Evaluation typically completes in microseconds.
  • User-supplied regex patterns (regex_match) are capped at 1024 bytes and a bounded compile size; oversized or invalid patterns evaluate false.
  • Deliberately has no wall-clock budget, fuel, or memory cap at evaluation time, and no proxy.scripting.cel block to set one. The language terminates by construction (no loops, no recursion), so the only unbounded work a CEL expression can request is regex compilation, and that is what the two regex caps bound. The cel transform logs when one header rule takes longer than a millisecond, but that budget is advisory: it is measured after the evaluation, and cannot preempt one.

Rego

  • One evaluation runs under budget_ms (default 50 ms, matching the extension-bundle sandbox default), enforced by the Regorus execution timer, which checks the deadline every thousand work units. budget_ms: 0 is refused at config load.
  • Deliberately has no memory or stack cap and no fuel: the execution timer bounds total work, and a policy is one bounded evaluation over an already-bounded input document rather than a body-sized stream.
  • Configured per policy (policies[] type: rego, field budget_ms), not under proxy.scripting. Modules are compiled and semantically checked at config load, including one trial evaluation, so authoring mistakes cannot defer to request time.
  • Every fault denies the request; there is no failure_posture knob on this surface. See §3a.

Lua

  • Fresh VM per invocation: globals never leak between calls.
  • Nil'd out: os, io, loadfile, dofile, require, rawset, rawget, load, loadstring, debug, package.
  • No network operations.
  • Wall-clock budget (default 100 ms) enforced via the Luau interrupt callback; memory cap (default 8 MB) enforced by the allocator.
  • Deliberately has no instruction metering: the interrupt callback bounds an infinite loop by wall clock, so counting instructions would duplicate a bound that already holds. Setting max_execution_ms: 0 disables the timer and is not recommended.
  • Available standard library: string.* (pattern functions gated by allow_patterns), table.*, math.*, tonumber, tostring, type, pairs, ipairs, select, pcall, error.

JavaScript

  • Fresh sandboxed engine per invocation; eval removed.
  • CPU budget (default 100 ms) enforced via a watchdog interrupt; heap cap (default 16 MB) and native stack cap (default 1 MB) enforced by the runtime. All three are tunable under proxy.scripting.javascript.sandbox and reload without a restart.
  • No filesystem, no network, no module loader.

WASM

  • Wasmtime sandbox running WASI preview-1. No network, no filesystem, no environment variables, no host clock beyond the epoch-interruption deadline.
  • Per-request Store so module state never leaks between requests; the compiled Module is shared across calls so per-invocation cost is one instantiate plus one _start.
  • timeout_ms is enforced via epoch interruption; max_memory_pages caps linear memory.
  • There is no host allowlist because there is nothing to allow: modules get no sockets. An authored allowed_hosts: is refused at config compile rather than accepted as a boundary nothing checks.

Capability matrix

One row per engine; every empty cell is a decision, not an omission, and the notes above say why. Extension bundles appear as their own row because their limits come from the bundle manifest rather than from sb.yml.

Engine Wall clock Memory Stack Fuel Output cap Limits configured in
CEL none (terminates by construction; regex caps only) none none none none nowhere: the regex caps are fixed
Rego budget_ms, default 50 ms none none none none per policy (budget_ms)
Lua 100 ms interrupt 8 MB allocator none none (wall clock covers it) none proxy.scripting.lua.sandbox
JavaScript (inline) 100 ms watchdog 16 MB heap 1 MB native none (wall clock covers it) none proxy.scripting.javascript.sandbox
WASM (transform: wasm) timeout_ms, default 1 s, epoch interruption max_memory_pages, default 256 pages (16 MiB) module-internal max_fuel, default 10^9 none the transform's own config block
Bundle hooks (JS / envelope WASM) budget_ms, default 50 ms, max 1 s memory_mb, default 16 MB, max 64 MB stack_kb, default 512 KB, max 2 MB max_fuel (WASM only), default 10^8 max_output_bytes, default 1 MiB, max 16 MiB; input capped by max_buffer_bytes the bundle manifest's sandbox: block

Two absences worth naming across the whole table. No inline engine caps its output size; the surfaces they attach to bound the result instead (a header value, a JSON body already capped by max_body_size, a log field). And no engine gets network, filesystem, or clock access anywhere in this table; the only I/O an extension can request is the declared, host-mediated kind documented in extension-bundles.md.

Where each engine attaches is section 2's table; what each surface hands the script is §3.2 for CEL and the per-engine sections above for the rest; what happens when a script fails is the error table in §11.


10. Performance notes

CEL evaluates in microseconds per request and fits any routing decision, including high-frequency hot paths. Prefer CEL over Lua, JavaScript, or WASM when the logic fits.

Lua and JavaScript build a fresh interpreter state per invocation. That is the isolation guarantee, and it means simple scripts complete in well under a millisecond but there is no cross-request state to amortize into.

WASM has a one-time compilation cost at config load; subsequent invocations run at near-native speed inside the Wasmtime sandbox, paying one instantiation per request.

Tips:

  • Prefer startsWith, endsWith, or contains over regex_match in CEL hot paths.
  • In Lua, use local variables. Local variable access is faster than global lookup.
  • In Lua, prefer table.concat() over string concatenation in loops.
  • Keep scripts under ~30 lines. If you need more, consider whether a typed modifier, transform, or policy fits better.
  • Expressions that always return the same result regardless of request data should be replaced with static config values.

11. Debugging scripts

Config validation

Validate your config before deployment:

sbproxy validate sb.yml

Validation checks the YAML shape and typed fields, and compiles every CEL expression the config declares: expression and assertion policies, rate-limit and WAF persistent-block key: expressions, cel transform bodies and header rules, and engine: cel custom log fields. A CEL syntax error in any of them surfaces here, and at boot and reload, rather than at request time. Inline Lua and JavaScript bodies are still strings to the validator, so their syntax errors surface at request time in the logs. The response_cache decision events are checked as far as they can be without running: an engine other than lua or js, or an empty source, is refused here and at boot and reload, while the script body stays a string until it runs. Dynamic bundle JavaScript and TypeScript are different: the candidate loader parses the entry, transpiles TypeScript when needed, verifies every named export, and refuses the candidate before publication when any of those steps fails.

Enabling debug logging

sbproxy --log-level debug -f sb.yml

With debug logging on, script failures are logged with the engine, the error message, and (for Lua and JavaScript) the failing function. Script health is also visible in metrics: sbproxy_script_compile_total{engine, result}, sbproxy_script_invocations_total{engine, result}, and sbproxy_script_duration_seconds{engine}.

Error behavior

Surface On error
expression policy A CEL parse error rejects the config at compile time (boot refuses to start; a reload keeps the previous config active); an evaluation error fails closed and denies the request (the expression could not prove the request is allowed)
assertion policy A CEL parse error rejects the config at compile time; an evaluation error is logged and recorded as a pass (the policy is log-only and gates nothing)
rate-limit key: A CEL parse error rejects the config at compile time; an empty or null result falls back to the default client key; an evaluation error buckets the request under the __cel_key_error__: namespace and logs
WAF persistent_block.key A CEL parse error, or track_by: cel with no key:, rejects the config at compile time; an evaluation error leaves the request untracked (no strike, no block)
engine: cel custom log field A CEL parse error rejects the config at compile time; an evaluation error is logged at debug and the field is omitted from the line
rego policy A module that fails to parse or a semantic fault (unsafe variable, query naming no rule) rejects the config at compile time; a rule error, non-boolean result, or exceeded budget_ms denies the request with deny_status
forward-rule when: A CEL parse error rejects the config at compile time; an evaluation error means the rule does not match, logged per request
WAF custom rule (Lua / JS) A rule whose script errors is skipped and counted; if no rule blocked but at least one went unevaluated, the pass reports a WAF failure and the policy's failure_posture decides (default closed)
Lua / JS modifiers Error logged per request; the modifier's headers are not applied; the request proceeds
lua_json / js_json / javascript transforms Error logged per request; the body is left unchanged
cel transform A missing or empty headers: array, a CEL parse error in any value_expr, an authored on_request: (removed; transforms have no request phase), or an authored on_response: / expression: (removed; CEL decides rather than produces) fails config compile; a runtime evaluation error skips only the failing header rule
WASM transform Missing module_path / module_bytes, a module that fails to compile, or an authored allowed_hosts: (removed; modules have no network surface) fails config compile; runtime errors skip the transform
response_cache.key_event An engine of cel or wasm, any other unknown engine, or an empty source fails config compile; an engine fault or a document that cannot be decoded is logged and bypasses the cache for that request, with no read and no write
response_cache.admit_event The same config-compile checks, plus a refusal when the origin also sets stale_while_revalidate; an engine fault or a document that cannot be decoded is logged and the response is stored under the configured ttl_secs
JavaScript / TypeScript bundle hook Invalid source, imports, a missing export, an invalid return envelope, timeout, or resource-limit error follows the bundle's failure_posture; candidate-load failures reject the whole candidate.
Envelope WASM bundle hook Invalid ABI, compile failure, malformed output, timeout, or resource-limit error follows failure_posture; candidate-load failures reject the whole candidate
Proxy-Wasm filter An unsupported import, invalid ABI, trap, resource-limit error, or unresolved Pause becomes a bounded filter failure and follows the resolved failure_posture

Common mistakes

CEL header key case. Headers are normalized to lowercase. Use request.headers["content-type"], not request.headers["Content-Type"].

CEL missing keys. Accessing a missing key can surface as an evaluation error, and the expression policy fails closed on evaluation errors. Guard with size(...) checks (e.g. size(jwt.claims) > 0) before indexing into maps that may be empty.

Lua array indexing is 1-based. arr[1] is the first element. #arr is the length.

Lua inequality operator. Lua uses ~= for not-equal, not !=.

Lua modifiers only set headers. Returning path, status_code, or body from modify_request / modify_response does nothing; those belong to the typed modifier fields (section 7).

AI policy CEL is a different namespace. The ai_policy expression sees ai.* variables, not request.*; see ai-policy-cel.md.


12. Dynamic extension bundles

For packaging and distributing JavaScript, TypeScript, and WASM behaviors as reusable components, SBproxy supports Extension Bundles.

For full details on the extension architecture, candidate loading, and manifest reference, see the dedicated Extension bundles guide.

Examples in Practice

To see custom scripting and transforms in action, refer to these runnable examples:

Example What it is How to use it Outcome
transform-javascript Payload rewriting via JavaScript. Use type: javascript in your transforms: block. Sandboxed QuickJS execution for JSON body edits.
transform-json Fast structural JSON edits. Use type: json with rename / remove / set steps. Cleanly add, remove, or rename JSON fields without a full JS runtime.
transform-json-schema Response JSON validation. Apply a type: json_schema transform. Rejects a malformed upstream response with a synthetic 502 before it reaches the client.
transform-markdown Markdown to HTML conversion. Use type: markdown. Renders a Markdown response body as HTML via pulldown-cmark.
transform-template Dynamic payload rendering. Use the template transform with minijinja (Jinja-style) syntax. Renders a structured JSON body into a plain-text (or other) summary.
variables-template Origin variable and env var interpolation. Reference {{ variables.<name> }} / {{ env.<NAME> }} in modifier fields. Injects config-defined values into headers sent upstream, resolved once when the config compiles (not per request).

See also