A policy gate and approval runtime for AI agent tool calls. An agent proposes a call. AgentGate canonicalises it, judges it against a JSON policy (default deny), asks a human when the policy says so, executes it at most once, and records every step in a hash-chained log.
The agents in this repo are scripted (src/scenarios.mjs), not language models. The product is the gate. A model-driven planner would implement the same next({ input, history }) interface; that is not built yet. The world (files, outbox, refund ledger, commands) is simulated: nothing sends email, moves money or runs a shell.
npm install
npm start # http://localhost:4200, in-memory store; the first visit creates the administrator
DATABASE_URL=postgres://... npm start # durable runs, accounts and sessions in Postgres
AUTH=off npm start # local demo only: no sign-in, anyone can approve
npm test # 75 tests (plus the Postgres store tests when TEST_DATABASE_URL is set)
npm run adversarial # prints the table below/ is the landing page and /app is the dashboard, both served by server.mjs from public/. The landing page is not a mock-up. Its live gate, its run viewer and its tamper check call a public, stateless playground API that runs the same prepareCall, evaluatePolicy, nextRecord and verifyChain code the runtime uses:
| Endpoint | What it does |
|---|---|
GET /api/play/meta |
the policy, the tools and the five scripted agents |
POST /api/play/check |
judge one call: effect, rules matched, canonical form, call hash |
POST /api/play/run |
run a scripted agent through the gate and return its hash-chained log (approve: true plays the approver) |
POST /api/play/verify |
re-check a chain a visitor edited and say where it first breaks |
It stores nothing and is rate limited per client (PLAY_RATE_LIMIT, default 60 a minute). test/play.test.mjs checks that its decisions and log events match a real runtime run for every scenario.
The dashboard is stateful (a simulated world in memory, a timer that expires old approvals, Postgres for runs and accounts), so it needs a long-running Node host. The playground is stateless, so it can run anywhere.
- Backend on Render: create a web service from
render.yaml(namedagentgate-api). SetDATABASE_URL(a Postgres connection string, for example from Neon) andSETUP_TOKEN, then open the service once and create the administrator.TRUST_PROXY=1is already set so rate limits and login lockout see the real client address. - Site on Vercel: import the repo.
vercel.jsonservespublic/, runsapi/play/*as Vercel functions, and rewrites every other/api/*request to the Render service. If your Render URL is nothttps://agentgate-api-hbbt.onrender.com, change it invercel.json.
If the backend is asleep or down, the landing page still works, and /app shows a "can't reach the server" screen.
Sign-in is on by default. The first visit shows a setup screen that creates the administrator; set SETUP_TOKEN before deploying so a stranger who finds the URL first cannot claim that account. The administrator adds people under Team.
| Role | Can do |
|---|---|
| viewer | read approvals, runs and policy |
| approver | also start runs, dry-run calls, approve and reject |
| admin | also manage people and reset the sandbox |
The approver recorded in the log is the signed-in user's email. A name sent by the browser is ignored. Passwords are hashed with scrypt, sessions are random tokens stored only as SHA-256 hashes, cookies are HttpOnly; SameSite=Strict (and Secure over HTTPS), every change needs a custom x-ag-csrf header, and repeated failed logins lock that email and address for 15 minutes.
| Property | How |
|---|---|
| The policy judges exactly what runs | Arguments are validated and canonicalised first (paths resolved, URL-decoded, case folded); the canonical form is what is hashed, judged and executed. |
| Default deny | A call no rule matches is denied. |
| Approval is for one specific call | The approver submits the call hash; a different hash is refused (409). |
| Four eyes | The approver cannot be the actor that proposed the call (403). With accounts on, the approver is the signed-in user, so it cannot be spoofed from the browser. |
| Approvals expire | 15 minutes by default; an expired call is never executed. |
| Policy is re-checked at execution | An approved call is judged again before it runs. |
| Effectively once | Execution uses an idempotency key; a retry after a crash does not repeat the side effect. |
| Slicing is caught | Refund rules look at the run's cumulative total, not only the single call. |
| Runaway agents stop | Call cap per run and a denial circuit breaker. |
| Tool output is data | Output from a tool is never interpreted as policy or instructions. |
| Tamper-evident history | Runs are folded from an append-only, hash-chained event log; verifyChain checks it. Optimistic concurrency (expectedSeq) stops two writers forking a run. |
56 hand-written cases in support/adversarial-cases.mjs: 44 attacks and 12 legitimate calls. Result of npm run adversarial:
| Category | Cases | Denied | Held for a human | Executed |
|---|---|---|---|---|
| paths | 13 | 12 | 1 | 0 |
| shell | 8 | 7 | 1 | 0 |
| 7 | 3 | 4 | 0 | |
| money | 8 | 7 | 1 | 0 |
| tools | 8 | 8 | 0 | 0 |
| All attacks | 44 | 37 | 7 | 0 |
Legitimate calls: 12. Allowed: 11. Held for a human by design: 1. Wrongly denied: 0. Side effects from attacks: 0. The false-block rate is reported next to the block rate on purpose, since a gate that blocks everything scores perfectly on attacks.
I wrote both the policy and the attacks, so this shows the gate does what the policy says against the cases I thought of. It is not a claim about attacks I did not think of.
I applied 15 deliberate bugs by hand (for example: remove the same-actor check, skip the hash comparison, skip re-checking policy at execution, ignore the cumulative total) and ran node --test after each. All 15 made at least one test fail. I did not keep the mutation script, so treat this as a result I observed, not one you can rerun with a command.
examples/agent.mjs is a small agent whose every tool call goes through the gate. The agent only proposes; the gate answers allow, hold for a person, or deny. Zero dependencies, and the world is simulated.
# No key needed: replay a scripted attack through the real engine
node examples/agent.mjs --mock prompt-injection
node examples/agent.mjs --mock salami-refunds
# Against the deployed playground instead of the local engine
node examples/agent.mjs --mock prompt-injection --base https://agentgate-lake.vercel.app
# A real model plans the calls (Gemini function calling)
GEMINI_API_KEY=... node examples/agent.mjs --task "Refund pay_AB12CD34 for 500 rupees and email the team"Add --ask in a terminal to approve held calls yourself. What you see in the prompt-injection run: the harmless read runs, the email to an outside address is held, the read of secrets/.env is denied. In the salami run, small refunds go through until the per-run total passes ₹2,000, then every further refund is held.
The Gemini path is covered by a test with a mocked API (request shape and result feedback). Live model behaviour varies and is not measured here.
- Secret detection is regex-based. It catches common key and private-key shapes, not arbitrary secrets.
- The ReDoS guard on patterns is a heuristic.
- An email to an internal address can still carry sensitive content; the content check is the same regexes.
- Accounts are email and password only: no email verification, password reset, SSO or two-factor yet. Lost passwords need an administrator to remove and re-add the person.
- Login throttling is in memory, so it resets on restart and is per server instance.
AUTH=offhas no identity at all and must never be exposed publicly.- The agent example can use a real model (Gemini), but the adversarial suite uses scripted planners, so nothing here measures how often a real model falls for prompt injection.
- Truncating the newest records of a log is not detectable without an externally stored head hash. The chain detects edits and deletions in the middle.
- The tool set is five simulated tools. Real tools need their own validators.
src/policy.mjs rules engine · src/tools.mjs validation, canonicalisation, simulated tools · src/runtime.mjs event-sourced run loop and approvals · src/chain.mjs hashing · src/store.mjs, src/store-pg.mjs storage · policies/default.json · src/auth.mjs accounts, sessions, roles · src/play.mjs, src/play-http.mjs public playground · src/* + api/ Vercel functions · examples/agent.mjs agent demo · server.mjs + public/ landing and dashboard · EXPLAIN.md interview prep.