ActionGate

September 20, 2026 · View on GitHub

ActionGate — runtime authorization for AI agent actions with TypeSafe Jev directly or through OpenRouter

ActionGate

Open-source Jev tool-calling authorization for AI agents. ActionGate is a runtime security gateway that evaluates a proposed tool call with deterministic policy plus TypeSafe Jev, called directly through TypeSafe or through OpenRouter, binds approval to that exact action, and refuses expired, changed, or replayed permits.

CI License Node.js Python Jev

Early public release. Use mock or sandbox tools while evaluating it. No external security review has happened yet — see the threat model and the independent-review tracker.


The problem

Schema validation proves a tool call is well-formed. It cannot prove the call matches what the user asked for. These two are both valid refund_payment calls:

The user saidThe agent proposedShould it run?
"Refund the duplicate $49 charge on txn_5512."refund txn_5512, 4900Yes
"Refund the duplicate $49 charge on txn_5512."refund txn_9981, 4900No — different transaction

ActionGate decides which is which, then issues a permit bound to the exact action it approved. Here it is against the live model:

The ActionGate simulator: the same refund tool is allowed for the transaction the user named, then blocked for a transaction they never mentioned, and blocked again when a question is treated as a request.

Jev supplies evidence. Code owns authority. A model score never overrides an RBAC, schema, limit, or duplicate failure.

Quick start

Node.js 22+ and pnpm 10+. No model key needed — it starts with a deterministic fake provider.

git clone https://github.com/omkarghugarkar007/actiongate-jev.git
cd actiongate-jev && corepack enable && cp .env.example .env
pnpm install && pnpm dev

Dashboard on :3000, simulator on :3000/simulator, API on :8080. Then pnpm refund:demo runs a guarded refund that cannot move real money.

For live decisions, choose either direct TypeSafe or OpenRouter:

# Direct TypeSafe API
DECISION_PROVIDER=typesafe
TYPESAFE_API_KEY=...
TYPESAFE_MODEL=jev-1.13.0

# Or OpenRouter
# DECISION_PROVIDER=openrouter
# OPENROUTER_API_KEY=sk-or-v1-...
# JEV_MODEL=typesafe/jev-1.13

The direct adapter is fully fixture-tested but has not yet been exercised live because no TYPESAFE_API_KEY is available. When you obtain one, run pnpm typesafe:smoke && pnpm test:typesafe:live; both fail loudly if it is missing. For tools with hard rules, also point ACTIONGATE_FACT_PROVIDER_URL at a deployment-owned fact service; the fake Tier 0 server uses only a labeled local fixture, and non-demo deployments fail closed without trusted evidence.

Guard a tool

TypeScript

import { FunctionFactProvider } from "@actiongate/core";
import { ActionGate } from "@actiongate/sdk";

const gate = ActionGate.embedded({
  // Read hard-rule evidence from application state, never agent input.
  factProviders: [new FunctionFactProvider({
    name: "payments",
    resolve: ({ request }) => paymentFacts(request.proposedAction.arguments)
  })]
});

const guardedRefund = gate.wrapTool({
  name: "refund_payment",
  operation: "refund",
  riskClass: "FINANCIAL",
  execute: async (input) => refundPayment(input.transactionId, input.amountCents),  // keep private
  buildRequest: async ({ runtime }) => ({
    requestId: crypto.randomUUID(),
    idempotencyKey: runtime.idempotencyKey,
    tenantId: "acme",
    environment: "production",
    mode: "enforce",
    actor: { agentId: "support-agent", userId: runtime.userId },
    userIntent: { text: runtime.userMessage, source: "user_message" }
  })
});

await guardedRefund({ transactionId: "txn_8923", amountCents: 4900 }, runtime);

When you outgrow one process, swap the constructor and nothing else changes:

const gate = new ActionGate({ apiKey: process.env.ACTIONGATE_API_KEY!, baseUrl: process.env.ACTIONGATE_URL! });
gate = ActionGate(api_key=os.environ["ACTIONGATE_API_KEY"], base_url=os.environ["ACTIONGATE_URL"])

Embedded keeps the same issue-and-consume guarantees but costs you a real boundary: grants live in memory, so they do not survive a restart or coordinate across replicas, there is no audit trail or review queue, and the policy sits in the same process as the agent — code that can edit it can raise its own limits. Hosted mode exists so the registry is somewhere the agent cannot reach.

Python

Same thing, no server:

from actiongate import ActionGate, Actor, UserIntent, ActionBlockedError

gate = ActionGate.embedded(
    fact_providers=[("payments", lambda request, _tool:
        payment_facts(request["proposedAction"]["arguments"]))]
)

def _refund(arguments, runtime):            # keep private
    return payments.refund(arguments["transactionId"], arguments["amountCents"])

guarded_refund = gate.wrap_tool(
    name="refund_payment",
    operation="refund",
    risk_class="FINANCIAL",
    execute=_refund,
    build_request=lambda arguments, runtime: {
        "tenant_id": "acme",
        "environment": "production",
        "mode": "enforce",
        "actor": Actor(agent_id="support-agent", user_id=runtime["user_id"]),
        "user_intent": UserIntent(text=runtime["user_message"]),
    },
)

try:
    guarded_refund({"transactionId": "txn_8923", "amountCents": 4900}, runtime)
except ActionBlockedError as error:
    print(error.decision, [reason["code"] for reason in error.reasons])

Both SDKs authorize, consume a single-use grant, and only then call the handler. Every error means the handler was not called.

ActionGate.embedded() runs the whole decision path in your process — no server, no API key, no base URL, no database. Run either demo to see it: pnpm examples:embedded or pnpm examples:embedded:python. Against the live model both refuse a transaction the user never named, on meaning rather than on a missing record.

A shared fixture suite makes the two implementations agree on canonical fingerprints, deterministic rules, and thresholds, so a grant issued by one verifies in the other.

Guard level. These protect the wrapper, not the function it calls. Keep the handler private, or another caller reaches it without a grant.

Without touching the agent

Point an MCP client or an HTTP caller at a proxy instead of the upstream service. Both credentials stay inside the proxy; the caller gets neither.

import { createSidecar } from "@actiongate/http-proxy";

createSidecar({
  actionGateUrl, actionGateApiKey,                  // stays in this process
  upstreamUrl: "http://payments.internal:9000",
  upstreamToken: process.env.UPSTREAM_TOKEN,        // stays in this process
  proxyToken: process.env.PROXY_TOKEN!,             // all the caller gets
  tenantId: "acme",
  routes: [{ method: "POST", path: "/refunds", tool: "refund_payment" }]
}).listen({ port: 8090 });

An unmapped route is a 404, never a pass-through.

For MCP, guarding a server is a config edit with no code — your client spawns ActionGate, ActionGate spawns the real server:

{ "mcpServers": { "payments": {
  "command": "npx",
  "args": ["tsx", "/path/to/actiongate-jev/scripts/mcp-guard.ts"],
  "env": { "UPSTREAM_COMMAND": "npx", "UPSTREAM_ARGS": "-y your-mcp-server", "TYPESAFE_API_KEY": "..." }
}}}

See guarding an MCP server for what that does and does not protect. One screen per integration level is in the quickstarts, and every connector states its boundary in the catalog.

What it costs to adopt

The boundary is strict; the on-ramp is not. Each tier is additive.

TierYou addYou get
0ActionGate.embedded()Guarded tools in one process — no server, no key, no database
1A TypeSafe or OpenRouter keyReal Jev semantic evidence instead of the deterministic fake
2The API serverA registry the agent cannot edit, plus audit, reviews, and incident controls
3Redis and PostgreSQLRestart-safe state, cross-replica consumption, encrypted durable evidence

How it works

Applications connect through SDK, MCP, HTTP, or webhook adapters. ActionGate combines tenant identity, a server-owned registry, deterministic authority fed by trusted server-side fact providers, and decision-model evidence before issuing a single-use grant that is consumed at the guarded execution boundary.

ActionGate asks six narrow Jev questions in one request — alignment, target match, policy conflict, sensitive-data exposure, scope expansion, missing intent — never one vague "is this safe?", and never uses model prose as an authorization reason.

Identity comes from a hashed tenant key. Tool operation, schema, risk, and policy come from a server-owned registry. Deterministic facts such as RBAC and spend must be resolved by server-side providers; request-body claims cannot satisfy a hard rule, so an agent cannot vouch for itself. Only an enforced ALLOW produces a grant, and it is consumed once before the side effect.

Full design: architecture · threat model · integrations · API description · runbooks · product plan.

Verification

pnpm lint && pnpm typecheck && pnpm test     # unit and package tests
pnpm test:integration                         # API, MCP, HTTP, review, incident paths
pnpm test:redis && pnpm test:postgres         # against real containers
pnpm test:python                              # Python SDK
pnpm e2e                                      # real browser
pnpm test:jev:live                            # real OpenRouter; spends credits
pnpm test:typesafe:live                       # direct TypeSafe; requires its own key

See a real refusal for one live decision record with its signals, cost, and latency.

pnpm verify:live runs everything end to end against a real stack — Redis, PostgreSQL, live OpenRouter, both SDKs over HTTP, both proxies with real upstream servers, the credential broker, the signed audit chain, and the UI in a real browser. Nothing in it is mocked.

The direct TypeSafe adapter is covered by offline wire-contract and boundary tests, but it is not yet live-verified because this project does not currently have a TYPESAFE_API_KEY. Run pnpm typesafe:smoke and pnpm test:typesafe:live before treating that route as verified.

Semantic quality is measured separately and honestly: pnpm eval:calibrate refuses unreviewed labels, and pnpm eval:drift blocks a promotion that raises the unsafe-allow rate. Reports include unsafe allow, auto-allow precision, review rate, and false-block rate by tool/action type. See the annotator guidance and the 500–1,000-case independent benchmark issue.

Status

Not on npm or PyPI: @actiongate/sdk there belongs to another project and actiongate on PyPI is taken. Cloning is the supported path, and it is verified from a fresh clone on every change — install, pnpm dev, both SDKs with and without a model key, both test suites, and the MCP guard over real stdio.

P0–P4 are engineering-complete: tenant-safe control plane, non-bypassable execution, review and incident workflow, calibration with drift gating, telemetry and quotas, tamper-evident exports, supply-chain provenance, the connector catalog, embedded mode in both SDKs, config-only MCP guarding, and the evidence loop.

Two release-readiness items remain, and neither is a code change. The semantic dataset's labels were authored alongside the system they test, so they stay generated and are excluded from quality reports until someone who did not write them reviews them. And no external security review has happened. Until both are done, treat this as an early public release.

ActionGate is an independent community project, not affiliated with or endorsed by TypeSafe AI or OpenRouter.

Built and maintained by Omkar Ghugarkar.

Contributing

Integrations, policy examples, and adversarial cases are welcome — see the contribution guide. Security reports follow the security policy, not public issues.

If ActionGate helps you build safer agents, star the repository.

License

Apache 2.0. See LICENSE.