Threshold Docs  /  Rule Scripts

Permission Rule Scripts

JavaScript/TypeScript functions that run before every proxied operation to decide whether the call should be allowed or denied. They execute in an isolated V8 sandbox (via isolated-vm) with strict memory and time limits.

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)

FieldTypeDescription
kind"http"Discriminator
agent_idstring | nullDecoded from the JWT sub claim when the agent presented one; null for opaque t8ak_ keys or no key
methodstringHTTP method — "GET", "POST", …
urlstringFull target URL the agent requested
hoststringConvenience: parsed hostname (and port if non-default) from url
pathstringConvenience: parsed pathname + query from url
headersobjectHeaders that would be sent upstream — after the proxy has stripped agent keys and injected configured credentials
bodyunknownParsed JSON when the request was JSON; raw string for other text bodies; null for empty bodies
upstream_urlstringThe 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)

FieldTypeDescription
kind"mcp_tool_call"Discriminator
agent_idstringID of the agent making the tool call
tool_namestringNamespaced tool name, e.g. "github:search_repositories"
tool_original_namestringOriginal tool name before namespacing, e.g. "search_repositories"
connection_namestringUpstream MCP server name, e.g. "github"
connection_idstringUUID of the upstream connection
argumentsobjectTool 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 403 response 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

LimitDefault
Timeout1000 ms
Memory64 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]].