Functions (Lua lambdas)
A function — internally a lambda — is a chunk of Lua code attached to one of your MCP or LLM proxies as a lambda-type rule. AIronClaw runs the snippet in-process during the request lifecycle and exposes a curated aifw.* global table for reading and mutating the request, the response, and a small bag of per-request context. Use them when none of the typed rules (rate_limit, ip_acl, response_replace, prompt_guard, ...) cover what you want to do.
Concepts#
Functions are the firewall's extension point. The typed rules handle 90% of cases — when you need the missing 10%, write Lua. Common use cases:
- Inject a per-caller value (user id, tenant tag, correlation ID) into a tool-call argument or an upstream header before the request leaves the firewall.
- Validate or reject requests with custom logic (regex match on a prompt, business-rule check on tool arguments, contract enforcement).
- Transform request or response bodies — redact a field, rewrite a model id, normalize an argument shape.
- Sign or stamp outbound traffic — HMAC, UUID correlation, timestamp.
Execution model#
Two phases, each scoped to one or more tools (or the whole proxy).
| Phase | When it runs | Typical use |
|---|---|---|
access | After authentication and built-in rules, right before forwarding upstream. | Read/modify the inbound request, inject upstream headers, short-circuit with an error. |
response | After the upstream replies, with the body buffered. MCP only — the LLM proxy rejects phase: response lambdas. | Read/modify the response body before the client sees it. |
Scope is set per rule via tools:
["*"]— runs for every request to this proxy.["search", "fetch"]— runs only when those tools are called (MCP). On LLM proxies use["*"].
Lambdas run inside a Lua sandbox. The only globals exposed are aifw and setmetatable. External I/O — HTTP, Redis, sockets, file system, environment — is not available from a lambda. If you need to consult an external service before deciding whether to allow a call, use a typed prompt_guard rule instead.
To read the caller's pre-firewall Authorization header after the firewall has substituted its own bearer for upstream proxying, use aifw.context.original_authorization.
How to attach a lambda#
A lambda is just a rule on the proxy. Add it via the dashboard's Rulestab, or directly via the management API by writing the rule into the proxy's rule array. Rule shape:
{
"rule_type": "lambda",
"enabled": true,
"phase": "access",
"tools": ["*"],
"name": "human-readable-id",
"lua_code": "...your Lua..."
}name shows up in logs as [aifw-lambda:<phase>/<scope>] and is helpful when debugging. Multiple lambda rules on the same proxy run in array order.
aifw.context — request bag#
A virtual table that lets you read and write the request body and pull plugin-level context. Reads decode JSON for you; writes re-encode and push the new body upstream.
| Key | R/W | Description |
|---|---|---|
aifw.context.body | R/W | JSON-decoded request body. Assigning a new table re-encodes and replaces the upstream-bound body. Returns nil for non-JSON bodies. |
aifw.context.request_body | R | Raw inbound body string. |
aifw.context.response_body | R/W | Buffered upstream response body. Only meaningful in phase: response. |
aifw.context.model | R/W | LLM-only: shortcut for body.model. Read or assign. |
aifw.context.messages | R/W | LLM-only: shortcut for body.messages. |
aifw.context.auth | R | Read-only identity object. { mode = "aifw_api_key", api_key = { name, permissions } } or { mode = "jwt", jwt = { claims, header, raw } }. |
aifw.context.tool_name | R | (MCP, access phase) Name of the tool being called. May include a shared-MCP prefix, e.g. "web-search__search". |
aifw.context.mcp_uuid | R | (MCP) The proxy's UUID. |
aifw.context.original_authorization | R | The inbound Authorization header captured before the firewall substituted it. |
aifw.context.shared_mcp_id | R | (MCP) When the call routed to a shared catalog tool, the catalog id of the shared upstream. |
aifw.request — symmetric request ops#
aifw.request and aifw.service.request point to the same ops table — pick whichever reads more naturally. Reads pull from the inbound request; writes mutate the upstream-bound request and are flushed at proxy phase.
Reads (downstream / what the client sent)#
get_method() -- string HTTP method
get_path() -- string request path (no query)
get_query() -- table query string parsed
get_headers() -- table all inbound headers (lower-case keys)
get_header(name) -- string|nil single header
get_body() -- string|nil raw inbound body
get_raw_body() -- string|nil alias of get_body
client_ip() -- string forwarded-for-awareShort aliases method/path/query/headers are kept for back-compat.
Writes (upstream-bound — applied at proxy phase)#
set_header(name, value)
add_header(name, value)
clear_header(name)
set_headers(t)
set_body(raw) -- alias of set_raw_body
set_raw_body(raw)
set_method(m)
set_path(p)
set_query(args) -- table form
set_raw_query(s) -- raw string formFor JSON bodies prefer aifw.context.body — it handles decode/encode for you. Use set_raw_body only when you intentionally want to send a non-JSON payload.
aifw.response.exit(status, body, headers?)#
Stops the firewall from reaching the upstream and returns status with body (a string or a Lua table that gets JSON-encoded automatically) to the caller. Aborts the lambda immediately — nothing after it runs.
aifw.log.{debug,info,notice,warn,err}(...)#
Wraps the gateway's logger and auto-prefixes every line with [aifw-lambda:<phase>/<scope>] so you can grep your gateway logs by rule.
Utilities#
| Module | Functions | Notes |
|---|---|---|
aifw.json | encode(v), decode(s) | cjson.safe |
aifw.base64 | encode(s), decode(s) | |
aifw.hex | encode(s), decode(s) | |
aifw.uri | escape(s), unescape(s) | URL-encoding |
aifw.re | match(s, re), find(s, re), gmatch(s, re), gsub(s, re, repl), sub(s, re, repl) | PCRE via ngx.re; JIT-compiled and cached by default (options="jo") |
aifw.hash | md5(s), sha1(s), sha256(s) | hex output |
aifw.hmac | md5(k, m), sha1(k, m), sha256(k, m), sha512(k, m) | hex output |
aifw.random | bytes(n), hex(n) | CSPRNG, n in [1, 256] |
aifw.uuid() | UUID v4 string | |
aifw.time | now() (float seconds), time() (epoch int) |
Examples#
1. Log the inbound request body#
Useful for debugging when you don't yet know what the client is sending.
-- phase: access · tools: ["*"]
local body = aifw.context.body
if not body then return end
aifw.log.info(
"method=", body.method,
" tool=", aifw.context.tool_name,
" args=", aifw.json.encode(body.params and body.params.arguments or {})
)2. Inject a tool-call argument#
Add a server-side value to params.arguments before the body reaches the upstream.
-- phase: access · tools: ["*"]
local body = aifw.context.body
if not body or body.method ~= "tools/call" then return end
body.params = body.params or {}
body.params.arguments = body.params.arguments or {}
body.params.arguments.caller = aifw.context.mcp_uuid
body.params.arguments.request_id = aifw.uuid()
-- Re-encodes and pushes the new body upstream in one assignment.
aifw.context.body = body3. Forward the caller's bearer token to a tool#
The firewall strips the inbound Authorizationon its way to the upstream (it's an AIronClaw API key, not an upstream secret). When you need the caller's original bearer to authenticate a tool against a third-party service, read it from aifw.context.original_authorization and inject the token into a tool argument.
-- phase: access · tools: ["*"]
local auth = aifw.context.original_authorization
if not auth then return end
-- "Bearer abc123..." → "abc123..."
local token = string.match(auth, "^[Bb]earer%s+(.+)$")
if not token then return end
local body = aifw.context.body
if not body or body.method ~= "tools/call" then return end
body.params = body.params or {}
body.params.arguments = body.params.arguments or {}
body.params.arguments.user_token = token
aifw.context.body = body4. Add a correlation ID header upstream#
Stamp every upstream request with a UUID so you can cross-reference firewall logs with upstream logs.
-- phase: access · tools: ["*"]
local cid = aifw.uuid()
aifw.service.request.set_header("X-Correlation-Id", cid)
aifw.log.info("correlation=", cid)5. Reject a tool call when the prompt matches a regex#
Custom guardrail: if the user's tool argument contains a forbidden term, block the request with a JSON-RPC error. aifw.response.exit aborts the lambda and short-circuits the upstream.
-- phase: access · tools: ["search"]
local body = aifw.context.body
if not body or body.method ~= "tools/call" then return end
local query = body.params
and body.params.arguments
and body.params.arguments.query
if type(query) ~= "string" then return end
if aifw.re.find(query, [[(?i)\b(insider\s+trading|nuclear\s+codes)\b]]) then
aifw.log.warn("blocked tool call, query=", query)
aifw.response.exit(403, {
jsonrpc = "2.0",
id = body.id,
error = { code = -32603, message = "Forbidden", data = "Query not allowed" },
})
end6. Rewrite the LLM model based on API key permissions#
On an LLM proxy, downgrade callers without the tier:gold tag from a premium model to a cheaper one. aifw.context.model is a shortcut to body.model that re-encodes for you on assignment.
-- phase: access · tools: ["*"]
local perms = aifw.context.auth
and aifw.context.auth.api_key
and aifw.context.auth.api_key.permissions
or {}
local is_gold = false
for _, t in ipairs(perms) do
if t == "tier:gold" then is_gold = true; break end
end
if not is_gold and aifw.context.model == "gpt-4.1" then
aifw.context.model = "gpt-4.1-mini"
aifw.log.info("downgraded gpt-4.1 → gpt-4.1-mini for non-gold caller")
end7. Redact a field from the upstream response#
Response-phase lambdas (MCP only) receive the buffered upstream body in aifw.context.response_body. Decode, mutate, re-encode, write back.
-- phase: response · tools: ["*"]
local raw = aifw.context.response_body
if not raw or #raw == 0 then return end
local data = aifw.json.decode(raw)
if not data then return end
if data.result and data.result.email then
data.result.email = "[redacted]"
end
aifw.context.response_body = aifw.json.encode(data)8. HMAC-sign an outgoing header#
Stamp every upstream request with a timestamp and an HMAC-SHA-256 signature so the upstream can verify the call actually came through AIronClaw.
-- phase: access · tools: ["*"]
local SECRET = "shared-secret-with-upstream" -- store in your secret manager
local ts = tostring(aifw.time.time())
local sig = aifw.hmac.sha256(SECRET, ts .. ":" .. (aifw.context.tool_name or ""))
aifw.service.request.set_header("X-Aifw-Ts", ts)
aifw.service.request.set_header("X-Aifw-Sig", sig)