HOW IT WORKS

The LLM calls Gatekeeper. Gatekeeper calls the tool.

Your agent loses direct access to subprocess, fs.writeFile, and fetch. Instead, it calls a local HTTP endpoint. That endpoint decides whether the action happens — and writes a receipt either way.

Request flow

  1. 1.
    Agent calls Gatekeeper. POST /v1/tool with a signed principal, tool name, and arguments.
  2. 2.
    Policy evaluation. Deny patterns → tool-specific rules → principal/role constraints. Sub-millisecond.
  3. 3.
    Decision. One of: allow, approve (pending), or deny.
  4. 4.
    If approve: Gatekeeper generates an HMAC-signed, single-use URL. You click it. The request resumes.
  5. 5.
    Execute. Gatekeeper runs the tool (not the agent). Secrets stay on the gatekeeper side.
  6. 6.
    Audit. Every step — request, decision, approval, execution, errors — appended to the audit log.

Calling Gatekeeper from an agent

Via the TypeScript client. The client is thin — mostly a typed wrapper around the HTTP API so you can swap in any other language client trivially.

import { GatekeeperClient } from "@runestone-labs/gatekeeper-client";

const gk = new GatekeeperClient({
  baseUrl: "http://localhost:7337",
  principal: { agent: "finance-sync", role: "local_dev" },
});

// The agent doesn't call fetch directly.
const res = await gk.tool("http.request", {
  method: "GET",
  url: "https://finnhub.io/api/v1/quote?symbol=NVDA",
});

// Decision lands here. Either the response body, or
// { status: "pending_approval", approval_url: "..." }.

Policy is YAML, not code

The agent can't modify it at runtime. You can review it in a PR. Diff it in CI. Hash it into every audit event so you always know which policy was active when a decision was made.

# gatekeeper.policy.yaml
version: 1

principals:
  - match: { role: "local_dev" }
    grants: [shell.exec, files.write, http.request, memory.upsert]
  - match: { role: "prod" }
    grants: [http.request, memory.upsert]   # no shell in prod

tools:
  shell.exec:
    allowed_cwd_prefixes: ["/workspace/", "/tmp/runestone/"]
    deny_patterns: ["rm -rf", "curl | sh", ":(){ :|:& };:"]
    decision: approve

  http.request:
    allowed_hosts: ["*.finnhub.io", "api.coinbase.com"]
    deny_private_ips: true
    decision: allow

  files.write:
    allowed_paths: ["/workspace/**"]
    deny_extensions: [".env", ".pem", ".key"]
    decision: allow

Approval: HMAC-signed, single-use, no SaaS

When the decision is approve, Gatekeeper holds the request and returns a URL. The URL carries an HMAC signature (shared secret, not public-key — this is a local service) and a short-TTL nonce stored server-side.

  • Click once → request resumes.
  • Click twice → nonce is consumed, replay rejected.
  • Wait an hour → link expires, request stays blocked.
  • Forward it to a teammate — as long as they can reach your local Gatekeeper, it works.
{
  "status": "pending_approval",
  "approval_url":
    "http://localhost:7337/approve/abc123
     ?sig=3e9f...
     &exp=1745339291",
  "reason":
    "shell.exec with 'sudo' flagged for review",
  "request_id":
    "req_01HW..."
}

Audit log: append-only JSONL

Daily rotating file by default. Optional Postgres sink if you want to query it. Every event is stamped with the policy_hash in effect, so six months later you can prove which rules were active when a decision was made.

{"ts":"2026-04-22T14:03:11.204Z","event":"request","actor":"agent:finance-sync","role":"local_dev","tool":"http.request","args_digest":"sha256:bb4..."}
{"ts":"2026-04-22T14:03:11.205Z","event":"decision","request_id":"req_01HW","decision":"allow","policy_hash":"sha256:a3f9..."}
{"ts":"2026-04-22T14:03:11.482Z","event":"executed","request_id":"req_01HW","duration_ms":276,"status":200}

{"ts":"2026-04-22T14:04:02.110Z","event":"request","actor":"agent:finance-sync","tool":"shell.exec","args":{"cmd":"sudo apt update"}}
{"ts":"2026-04-22T14:04:02.111Z","event":"decision","request_id":"req_01HX","decision":"approve","reason":"sudo requires approval"}
{"ts":"2026-04-22T14:04:37.009Z","event":"approval_consumed","request_id":"req_01HX","approver":"evan@runestonelabs.io"}
{"ts":"2026-04-22T14:04:40.223Z","event":"executed","request_id":"req_01HX","duration_ms":3200,"status":"ok"}

Cost audit — agents spend real money

LLM calls are tool calls too. Gatekeeper prices each one (per-model input/output tokens, cache hits, tool-use premiums) and stamps the cost into the audit log. You see actual dollars per agent, per workflow, per day — no estimation, no sampling.

Set budgets the same way you set policy. They ship dormant — observe real spend via /usage and /llm-usage for a couple weeks, then pin caps at ~2× p95 with confidence.

  • Per-agent, per-workflow, per-tool budgets.
  • Rolling windows: daily, weekly, monthly.
  • Over-budget decision is first-class: allow, approve, or deny.
  • Cache-hit credit — Anthropic prompt caching shows up as its own line item.
budgets:
  agent:finance-sync:
    daily_usd: 2.00
    decision: approve     # over-budget → pause

  agent:research_*:
    monthly_usd: 50.00
    decision: deny        # hard stop

  workflow:nightly-scrape:
    daily_usd: 5.00

Want the full architecture doc?

The repo has a longer architecture write-up, a policy guide, and an approval flow doc. All source, no marketing.