Where rules run
Rules are evaluated by two call sites today:
- Threshold backend — runs rules before forwarding an MCP tool call to its upstream server. Rules are assigned to agents via
agent.config.rule_ids. - T8 Engine — runs rules before forwarding any proxied HTTP request. Rules are declared in the t8 TOML config and apply to every proxied request.
Both call sites use the same rule-runner service, the same script signature, and the same return contract. The only difference is the shape of the ctx argument — see the Context schema section below.
Function signature
function rule(ctx: RuleContext): RuleResult
The script must define a top-level rule function. Its return value determines the decision.
Context schema
Rules can be invoked from multiple proxying paths. To let one rule serve all of them, every context carries a kind discriminator. Inspect it to branch:
function rule(ctx) {
if (ctx.kind === "http") {
// ctx.method, ctx.url, ctx.headers, ctx.body, ...
} else if (ctx.kind === "mcp_tool_call") {
// ctx.tool_name, ctx.arguments, ...
}
return { action: "allow" };
}
kind: "http" — proxied HTTP request (T8 Engine)
| Field | Type | Description |
|---|---|---|
kind | "http" | Discriminator |
agent_id | string | null | Decoded from the JWT sub claim when the agent presented one; null for opaque t8ak_ keys or no key |
method | string | HTTP method — "GET", "POST", … |
url | string | Full target URL the agent requested |
host | string | Convenience: parsed hostname (and port if non-default) from url |
path | string | Convenience: parsed pathname + query from url |
headers | object | Headers that would be sent upstream — after the proxy has stripped agent keys and injected configured credentials |
body | unknown | Parsed JSON when the request was JSON; raw string for other text bodies; null for empty bodies |
upstream_url | string | The URL t8 will actually call (after any rewrite_prefix) |
via | "localConfig" | "agentConfig" | "passThrough" | Which routing source served this request |
Example:
{
"kind": "http",
"agent_id": "agent-abc123",
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"host": "api.anthropic.com",
"path": "/v1/messages",
"headers": { "content-type": "application/json", "authorization": "Bearer sk-…" },
"body": { "model": "claude-3-5-sonnet", "max_tokens": 1024 },
"upstream_url": "https://api.anthropic.com/v1/messages",
"via": "localConfig"
}
kind: "mcp_tool_call" — proxied MCP tool call (backend)
| Field | Type | Description |
|---|---|---|
kind | "mcp_tool_call" | Discriminator |
agent_id | string | ID of the agent making the tool call |
tool_name | string | Namespaced tool name, e.g. "github:search_repositories" |
tool_original_name | string | Original tool name before namespacing, e.g. "search_repositories" |
connection_name | string | Upstream MCP server name, e.g. "github" |
connection_id | string | UUID of the upstream connection |
arguments | object | Tool call arguments as key-value pairs |
Example:
{
"kind": "mcp_tool_call",
"agent_id": "agent-abc123",
"tool_name": "github:create_issue",
"tool_original_name": "create_issue",
"connection_name": "github",
"connection_id": "d4e5f6a7-…",
"arguments": { "owner": "acme", "repo": "infra", "title": "Deploy to prod" }
}
When MCP traffic migrates from the backend into t8engine, the same kind: "mcp_tool_call" context will be emitted there too — existing rules continue to work unchanged.
Output: RuleResult
Return one of:
// Allow the call
{ action: "allow" }
// Block the call
{ action: "deny", reason: "human-readable explanation" }
Only an explicit { action: "deny" } blocks the call. Any other return value (including undefined, other objects, or thrown errors) defaults to allow. The system is fault-tolerant: if the rule runner is unreachable or the script crashes, the call proceeds.
When a rule denies, the proxy surfaces:
- MCP: a JSON-RPC error to the agent containing the
reason. - HTTP (t8engine): a
403response body of{"error":"Denied by rule","reason":"…","ruleId":"…"}.
Examples
Allow only specific agents (MCP):
function rule(ctx) {
if (ctx.kind !== "mcp_tool_call") return { action: "allow" };
const allowed = ["agent-prod-1", "agent-prod-2"];
if (!allowed.includes(ctx.agent_id)) {
return { action: "deny", reason: "agent not in allowlist" };
}
return { action: "allow" };
}
Block requests to specific hosts (HTTP):
function rule(ctx) {
if (ctx.kind !== "http") return { action: "allow" };
const blocked = ["evil.example.com", "exfil.invalid"];
if (blocked.some((h) => ctx.host === h || ctx.host.endsWith("." + h))) {
return { action: "deny", reason: `host ${ctx.host} is blocked` };
}
return { action: "allow" };
}
Restrict the model field on Anthropic messages (HTTP):
function rule(ctx) {
if (
ctx.kind === "http" &&
ctx.host === "api.anthropic.com" &&
ctx.path.startsWith("/v1/messages") &&
ctx.body && ctx.body.model &&
!ctx.body.model.startsWith("claude-")
) {
return { action: "deny", reason: `model ${ctx.body.model} not allowed` };
}
return { action: "allow" };
}
Enforce argument constraints (MCP):
function rule(ctx) {
if (ctx.kind !== "mcp_tool_call") return { action: "allow" };
const amount = ctx.arguments.amount;
if (typeof amount === "number" && amount > 10000) {
return { action: "deny", reason: `amount ${amount} exceeds limit of 10000` };
}
return { action: "allow" };
}
Enforce a per-request token budget (HTTP):
function rule(ctx) {
if (
ctx.kind !== 'http' ||
ctx.host !== 'api.anthropic.com' ||
!ctx.path.startsWith('/v1/messages') ||
!ctx.body
) return { action: 'allow' };
const cap = 4096;
if (typeof ctx.body.max_tokens === 'number' && ctx.body.max_tokens > cap) {
return {
action: 'deny',
reason: 'max_tokens ' + ctx.body.max_tokens + ' exceeds cap of ' + cap,
};
}
return { action: 'allow' };
}
Read-only mode for a specific upstream:
function rule(ctx) {
if (ctx.kind !== 'http') return { action: 'allow' };
if (ctx.host !== 'api.anthropic.com') return { action: 'allow' };
if (['DELETE', 'PUT', 'PATCH'].includes(ctx.method)) {
return { action: 'deny', reason: ctx.method + ' not permitted on Anthropic API' };
}
return { action: 'allow' };
}
Refuse to forward a leaked AWS key (TypeScript):
interface Ctx {
kind: string;
method: string;
host: string;
headers: Record<string, string>;
}
function rule(ctx: Ctx): { action: string; reason?: string } {
if (ctx.kind !== 'http') return { action: 'allow' };
for (const [name, value] of Object.entries(ctx.headers)) {
if (typeof value === 'string' && /AKIA[0-9A-Z]{16}/.test(value)) {
return { action: 'deny', reason: 'header ' + name + ' looks like an AWS key' };
}
}
return { action: 'allow' };
}
TypeScript is transpiled by esbuild inside the runner — no separate build step.
Debugging
console.log() is available inside rules. Output is captured and returned in the logs array of the execution response, visible in the rule tester UI.
function rule(ctx) {
console.log("kind:", ctx.kind, "url:", ctx.url ?? ctx.tool_name);
return { action: "allow" };
}
Execution limits
| Limit | Default |
|---|---|
| Timeout | 1000 ms |
| Memory | 64 MB |
Scripts cannot access Node.js globals, the filesystem, or the network. Each execution runs in a fresh V8 isolate with no shared state between calls.
Rule assignment
Backend (MCP tool calls)
Rules are created via the admin API or frontend, then assigned to agents. Multiple rules can be assigned to one agent — all are evaluated in order, and the first deny wins.
T8 Engine (HTTP requests)
Rules are declared inline in the t8 TOML config (T8_CONFIG env var or T8_CONFIG_FILE). They apply globally to every proxied request that t8 handles, in declaration order; first deny wins.
[[rules]]
id = "block-evil"
script = """
function rule(ctx) {
if (ctx.kind !== 'http') return { action: 'allow' };
if (ctx.host.endsWith('evil.com')) {
return { action: 'deny', reason: 'host blocked' };
}
return { action: 'allow' };
}
"""
[[routes]]
prefix = "https://api.anthropic.com"
[routes.headers]
authorization = "Bearer ${ANTHROPIC_API_KEY}"
The rule-runner URL is configured via the optional RULE_RUNNER_URL env var on the t8engine service. When unset, t8 skips rule evaluation entirely (default-allow). The t8 plugin wires this to the bundled rule-runner service automatically.
See T8 Engine → Config File Format for the full TOML grammar that surrounds [[rules]].