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.
| 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.
| 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.
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.
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.
| 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"], notrequest.headers["Content-Type"]orrequest.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"
| Field | Type | Description |
|---|---|---|
connection.remote_ip |
string | Client IP address, when known |
| 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'
When the claim expression comes back empty the limiter falls back to client IP (config).
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 |
| 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 |
| 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 |
| 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 "" |
| 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.
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.devThat 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
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.
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.
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"policies:
- type: expression
expression: 'request.path.startsWith("/api/") && request.method in ["GET", "POST"]'
deny_message: "only GET/POST under /api/"policies:
- type: expression
expression: 'ip_in_cidr(connection.remote_ip, "10.0.0.0/8")'
deny_status: 403
deny_message: "internal network only"policies:
- type: expression
expression: '"admin" in principal.attrs.roles || jwt.claims.role == "admin"'
deny_status: 403
deny_message: "admin role required"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."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"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.
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: passForward 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.
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.
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: 50The 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.
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.
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.
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.
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.
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.
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" } }
endOn 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.
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 capturedAnything 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.status_code -- numeric HTTP status
resp.headers -- response headers tableRequest 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, {} otherwiseThe 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.
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.
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
endA 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.
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
}
}
endrequest_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 }
}
endresponse_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"
}
}
endresponse_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 }
}
endtransforms:
- 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
endFor path rewriting, method overrides, query-string edits, and body replacement, use the typed modifier fields alongside (or instead of) a script; see section 7.
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.
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).
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.
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: 256Sandbox 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/.
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" };
})()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.
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.
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.
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.
| 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| 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.
The AI proxy action does not embed the general scripting engines. It has two dedicated surfaces:
ai_policy: a single sandboxed CEL expression over theai.*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/.
- 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.celblock 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. Theceltransform 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.
- 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: 0is 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, fieldbudget_ms), not underproxy.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_postureknob on this surface. See §3a.
- 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: 0disables the timer and is not recommended. - Available standard library:
string.*(pattern functions gated byallow_patterns),table.*,math.*,tonumber,tostring,type,pairs,ipairs,select,pcall,error.
- Fresh sandboxed engine per invocation;
evalremoved. - 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.sandboxand reload without a restart. - No filesystem, no network, no module loader.
- Wasmtime sandbox running WASI preview-1. No network, no filesystem, no environment variables, no host clock beyond the epoch-interruption deadline.
- Per-request
Storeso module state never leaks between requests; the compiledModuleis shared across calls so per-invocation cost is one instantiate plus one_start. timeout_msis enforced via epoch interruption;max_memory_pagescaps 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.
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.
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, orcontainsoverregex_matchin CEL hot paths. - In Lua, use
localvariables. 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.
Validate your config before deployment:
sbproxy validate sb.ymlValidation 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.
sbproxy --log-level debug -f sb.ymlWith 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}.
| 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 |
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.
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.
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). |
- configuration.md - general configuration model and the full
sb.ymlfield reference. - features.md - higher-level feature overview.
- ai-gateway.md - AI gateway routing and guardrails.
- ai-policy-cel.md - the unified CEL policy plane for the AI gateway.
- examples/extension-bundles - runnable JavaScript, TypeScript, envelope WASM, Proxy-Wasm, AI, and payment bundle examples.
