# Firewall rules — schema & worked examples

Rules are stored as an **array** on the MCP or LLM proxy's the gateway plugin instance and evaluated **in order** for every request. Updates are atomic and full-replacement: `PUT /api/{mcp,llm}/:id/rules` overwrites the whole array.

To add or remove a single rule:

```bash
RULES=$(curl -fsS -H "Authorization: Bearer $T" "$URL/api/mcp/$ID/rules" | jq '.rules')
NEW=$(echo "$RULES" | jq '. + [<new-rule>]')   # or jq 'del(.[<i>])' to remove
curl -fsS -X PUT -H "Authorization: Bearer $T" -H "Content-Type: application/json" \
  "$URL/api/mcp/$ID/rules" -d "{\"rules\": $NEW}"
```

Every rule has these common fields:

| Field      | Type            | Notes |
|-----------|------------------|-------|
| `rule_type` | string          | discriminator, see below |
| `enabled`   | boolean (true)  | toggle without deleting |
| `tools`     | string[]        | tool names this rule applies to, or `["*"]` for all (LLM proxies: still `["*"]` — there is no "tool" concept, but the field is required) |

Rule types valid on **MCP** proxies: `ip_acl`, `rate_limit`, `tool_description_inject`, `response_replace`, `lambda`, `static_cache`, `mcp_resource`.

Rule types valid on **LLM** proxies: `ip_acl`, `rate_limit`, `prompt_replace`, `prompt_guard`, `model_route`, `lambda` (phase=`access` only), `static_cache`.

The schema rejects mismatches (e.g. `response_replace` on LLM, `prompt_guard` on MCP) with a 400 at PUT time.

---

## ip_acl

Allow or deny by IP / CIDR.

```json
{
  "rule_type": "ip_acl",
  "tools": ["*"],
  "action": "allow" | "deny",
  "cidrs": ["203.0.113.0/24", "2001:db8::/32", "198.51.100.42"]
}
```

- An `allow` rule means *only* listed IPs are allowed for the matched tools (default-deny). A `deny` rule blocks listed IPs (default-allow). Don't mix the two senses on the same tool — first match wins.
- Both IPv4 and IPv6 supported. `/32` (v4) and `/128` (v6) are implicit when no slash.

---

## rate_limit

Sliding-window rate limit with optional ban-after-N-exceeded.

```json
{
  "rule_type": "rate_limit",
  "tools": ["*"],
  "name": "global-per-key",
  "match_key": "ip" | "user_agent" | "consumer" | "api_key" | "tokens_per_minute",
  "threshold": 100,
  "timespan": 60,
  "dryrun": false,
  "reset_expire_on_hit": false,
  "ban_after_n_exceeded": null,
  "ban_timespan": null
}
```

- `name` must be `[a-zA-Z0-9_-]+`, unique per proxy.
- `match_key`:
  - `ip`, `user_agent`, `consumer` (consumer record), `api_key` — request-count rate limit
  - `tokens_per_minute` — **LLM-only**. Counts tokens, not requests; `threshold` is tokens, `timespan` should be 60.
- `dryrun: true` logs but does not block. Useful when introducing a new limit.
- `reset_expire_on_hit: true` makes the window sliding (TTL refreshed on every hit) instead of fixed.
- `ban_after_n_exceeded: 50` + `ban_timespan: 3600` → after 50 over-threshold requests, ban the matched key for 1h. Bans short-circuit pre-auth on the next request.

---

## tool_description_inject (MCP-only)

Append or prepend text to the upstream tool descriptions returned by `tools/list`. Use to inject usage warnings, deprecation notices, or extra context into the tool catalog the agent sees.

```json
{
  "rule_type": "tool_description_inject",
  "tools": ["delete_customer"],
  "inject_text": "DESTRUCTIVE — confirm with the user before calling.",
  "inject_position": "prepend" | "append"
}
```

- The injection happens at `tools/list` time, not on call. Agents that cache the catalog will still see it.

---

## response_replace (MCP-only) — DLP

Regex-based redaction of upstream tool responses before they reach the client.

```json
{
  "rule_type": "response_replace",
  "tools": ["search", "fetch"],
  "pattern": "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}",
  "replacement": "[REDACTED-EMAIL]",
  "regex_flags": "i",
  "dlp_rule_id": "email_v1"
}
```

- `pattern` is PCRE.
- `replacement` may reference captures: `$0` (full match), `$1`..`$9` (groups). Use empty string to drop matches entirely.
- `regex_flags`: `i` case-insensitive, `s` dot-all, `m` multiline.
- `dlp_rule_id` is opaque metadata that the dashboard's DLP catalog uses to identify the rule. Optional — set when you want this rule to be visible as a "managed DLP rule" in the UI.

For LLM proxies, the equivalent is **`prompt_replace`** which redacts the *prompt* before forwarding (LLM responses are never mutated by AIronClaw).

```json
{ "rule_type": "prompt_replace", "tools": ["*"], "pattern": "...", "replacement": "[REDACTED]" }
```

---

## prompt_guard (LLM-only)

Detect and act on prompt-injection / jailbreak patterns.

```json
{
  "rule_type": "prompt_guard",
  "tools": ["*"],
  "mode": "regex",
  "phase": "request",
  "detectors": ["pi_ignore_previous", "pi_role_play", "pi_dan"],
  "action": "block" | "rewrite" | "alert",
  "rewrite_template": "[redacted]"
}
```

- `mode: "regex"` runs the named detectors from the plugin's `guard_detectors.lua` library. Available detector ids are listed in the dashboard's "Functions" page.
- `mode: "judge"` is reserved (config-accepted, runtime-disabled in the current build — don't use yet).
- `phase: "request"` runs pre-dispatch (can block). `phase: "response"` is alert-only.
- `action: "block"` returns 403 to the client. `"rewrite"` substitutes the matched text with `rewrite_template` (supports `$0`, `$1`...). `"alert"` only emits an event.

---

## model_route (LLM-only)

Force the model to be rewritten when the prompt matches a regex. Useful for routing reasoning prompts to a stronger model without trusting the client to ask for it.

```json
{
  "rule_type": "model_route",
  "tools": ["*"],
  "pattern": "(?i)\\b(reason|prove|step.by.step)\\b",
  "regex_flags": "i",
  "target_model": "o1"
}
```

The regex is evaluated against the concatenation of all `role="user"` message contents. First-match wins; the request reaches the upstream with `model: target_model` regardless of what the client asked for.

---

## lambda

Run user-supplied Lua code in a Lua sandbox. Powerful and best used sparingly.

```json
{
  "rule_type": "lambda",
  "tools": ["*"],
  "phase": "access" | "response",
  "name": "human-readable-id",
  "lua_code": "aifw.log.info(\"hi from \", aifw.context.tool_name)"
}
```

- `phase: "access"` runs before forwarding (can short-circuit via `aifw.response.exit(...)`).
- `phase: "response"` runs after the upstream replies (can mutate the response body) — **MCP only**; LLM rejects.
- Code runs in a sandbox: only `aifw` and `setmetatable` are exposed as globals; external I/O (HTTP, sockets, file system) is not available from a lambda.

Use sparingly; almost everything you might write a lambda for is better expressed as a typed rule. When you do need one, consult **[reference/lambda-functions.md](lambda-functions.md)** — the deep-dive covers the full `aifw.*` surface (request/response read & mutate, identity, JSON/HMAC/regex/UUID utilities, `aifw.response.exit`) and ships eight worked examples (inject a tool argument, forward the caller's bearer, sign an upstream header, redact a response field, ...).

---

## static_cache

Cache idempotent responses keyed on the request payload.

```json
{
  "rule_type": "static_cache",
  "tools": ["search"],
  "cache_ttl": 3600,
  "cache_isolation": "per_identity" | "shared",
  "cache_identity_claims": ["iss", "sub", "aud"]
}
```

- `cache_ttl` 60..86400 seconds.
- `cache_isolation: "per_identity"` (default, safe) — each caller gets their own cache slot. The identity is the API key's credential id when `auth.mode=aifw_api_key`, or the JWT claims listed in `cache_identity_claims` when `auth.mode=jwt`.
- `cache_isolation: "shared"` — all callers share one slot. **Only safe when the response does not depend on the caller** (e.g. public reference data). Easy to leak across tenants if misused.

The cache can be drained for a single tool with `DELETE /api/mcp/:id/cache?tool=<name>`.

---

## mcp_resource (MCP-only)

Expose synthetic content as both an MCP resource (visible via `resources/list` + `resources/read`) and optionally as a synthetic tool. Use for pinned instructions, glossaries, or pre-built prompts.

```json
{
  "rule_type": "mcp_resource",
  "tools": ["*"],
  "resource_id": "guidelines-v1",
  "uri": "ironclaw://docs/usage-guidelines",
  "resource_name": "Usage guidelines",
  "description": "Internal guidelines clients must follow",
  "mime_type": "text/markdown",
  "content": "## How to use this MCP\n\n...",
  "expose_as_tool": true,
  "tool_name": "get_guidelines",
  "inject_into_tools": ["*"],
  "audience": ["assistant", "user"],
  "priority": 0.8
}
```

This is the rule-based form. There is also a higher-level CRUD endpoint (`POST /api/mcp/:id/resources`) that wraps this and is friendlier when you only want a static resource without the tool/inject options. Pick the rule form when you also want to inject the resource into other tools' responses (`inject_into_tools`).

---

## LLM-only rules quick recap

When working on an LLM proxy, only these are allowed:

`ip_acl`, `rate_limit` (with `tokens_per_minute` available), `prompt_replace`, `prompt_guard`, `model_route`, `lambda` (phase=`access`), `static_cache`.

Submitting any other type returns 400 with a message naming the offending rule index.
