Core resources

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).

PhaseWhen it runsTypical use
accessAfter authentication and built-in rules, right before forwarding upstream.Read/modify the inbound request, inject upstream headers, short-circuit with an error.
responseAfter 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 ["*"].
Sandboxed surface

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.

KeyR/WDescription
aifw.context.bodyR/WJSON-decoded request body. Assigning a new table re-encodes and replaces the upstream-bound body. Returns nil for non-JSON bodies.
aifw.context.request_bodyRRaw inbound body string.
aifw.context.response_bodyR/WBuffered upstream response body. Only meaningful in phase: response.
aifw.context.modelR/WLLM-only: shortcut for body.model. Read or assign.
aifw.context.messagesR/WLLM-only: shortcut for body.messages.
aifw.context.authRRead-only identity object. { mode = "aifw_api_key", api_key = { name, permissions } } or { mode = "jwt", jwt = { claims, header, raw } }.
aifw.context.tool_nameR(MCP, access phase) Name of the tool being called. May include a shared-MCP prefix, e.g. "web-search__search".
aifw.context.mcp_uuidR(MCP) The proxy's UUID.
aifw.context.original_authorizationRThe inbound Authorization header captured before the firewall substituted it.
aifw.context.shared_mcp_idR(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-aware

Short 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 form

For 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#

ModuleFunctionsNotes
aifw.jsonencode(v), decode(s)cjson.safe
aifw.base64encode(s), decode(s)
aifw.hexencode(s), decode(s)
aifw.uriescape(s), unescape(s)URL-encoding
aifw.rematch(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.hashmd5(s), sha1(s), sha256(s)hex output
aifw.hmacmd5(k, m), sha1(k, m), sha256(k, m), sha512(k, m)hex output
aifw.randombytes(n), hex(n)CSPRNG, n in [1, 256]
aifw.uuid()UUID v4 string
aifw.timenow() (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 = body

3. 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 = body

4. 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" },
  })
end

6. 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")
end

7. 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)