Skip to main content

Runtime human-approval gatekeeper for AI agents

Project description

🛕 Toran

The runtime human-approval gatekeeper for AI agents

Framework-agnostic · Sub-millisecond · Self-hosted · One decorator

Rust CI License: MIT Rust Python Edition Binary Status

Quick start · How it works · Policies · SDK · Dashboard · Security


Toran is a gatekeeper. It sits between your AI agent and the real world. When the agent tries to send an email, write to a database, or call an API, Toran checks a policy. If the action is allowed, it executes immediately. If it is risky, Toran pauses and asks a human for approval — via the dashboard, Slack, email, or any custom webhook.

Toran works with any Python agent framework — 🦜 LangChain, 🚣 CrewAI, 🧩 Pydantic AI, 🤖 AutoGen, or a plain for loop. One decorator. No rewrite.

from toran import gate

@gate()
def send_email(to, subject, body):
    return mailgun.send(to, subject, body)

send_email("alice@company.com", "Lunch?", "1pm?")          # ✅ ALLOW   — runs instantly
send_email("stranger@evil.xyz", "Hi", "...")               # ⏸️  REQUIRE_APPROVAL — pings a human
send_email("ceo@x.com", "FREE MONEY winner!!!", "...")     # ⛔ BLOCK   — never sent

📑 Table of contents

🧭 First time? Read TOUR.md for a 5-minute guided walkthrough.


✨ Why Toran

🪶 Framework-agnostic One @gate() decorator. Works with LangChain, CrewAI, Pydantic AI, AutoGen, or no framework at all.
Sub-millisecond Compiled decision-tree evaluator. Low single-digit microseconds for 1,000 rules on modern x86_64.
📦 Single static binary 5.8 MB. No external services. Runs on a Raspberry Pi.
🔒 Self-hosted Your hardware, your data. Nothing leaves your machine unless you wire up a webhook.
🔥 Hot-reloaded policies Edit a YAML file; the change is live. No restart.
🧾 Tamper-evident audit log Append-only, optionally hash-chained with `SHA-256(prev

🚀 Quick start

1️⃣ Build the core

git clone https://github.com/Zyferon/toran.git
cd toran
cargo build --release

The single static binary is at target/release/toran.

2️⃣ Write a policy

# policies/email-guardian.yaml
name: email-guardian
default_action: ALLOW
rules:
  - name: block-spam
    tool: { exact: send_email }
    conditions:
      - key: subject
        op: regex
        value: '(?i)(viagra|free money|lottery winner)'
    action: BLOCK
  - name: approve-external
    tool: { exact: send_email }
    conditions:
      - key: to
        op: regex
        value: '^[^@]+@[^@]+\.(io|xyz|top|click)$'
    action: REQUIRE_APPROVAL
    timeout_secs: 300
  - name: allow-internal
    tool: { exact: send_email }
    action: ALLOW

3️⃣ Start the core

./target/release/toran start
# → 2026-…  toran socket server listening socket=/tmp/toran.sock
# → 2026-…  toran api listening  addr=127.0.0.1:7878

Open http://127.0.0.1:7878 for the dashboard.

4️⃣ Decorate your agent

from toran import gate, configure

configure(socket_path="/tmp/toran.sock", agent_id="prod-agent-1")

@gate()
def send_email(to, subject, body):
    return mailgun.send(to, subject, body)

# That's it. The decorator handles the rest.
send_email("alice@company.com", "Lunch?", "1pm?")           # → ALLOW
send_email("stranger@evil.xyz", "Hi", "...")                # → approval needed
send_email("ceo@x.com", "FREE MONEY winner!!!", "...")      # → BLOCK

⚙️ How it works

Toran is a five-layer system. Every layer is open source and runs on your hardware.

Layer Component Technology
1️⃣ Policy YAML rules, hot-reloaded serde_yaml, notify
2️⃣ Evaluation Compiled decision tree regex, HashMap
3️⃣ Blocking Tokio async wait tokio, mpsc
4️⃣ Notification Slack / webhook / console reqwest shim, tracing
5️⃣ Resolution Dashboard / webhooks axum

A request flows like this:

agent code → @gate → Unix socket → Rust core → policy eval
                                              ├─ ✅ ALLOW → original function runs
                                              ├─ ⛔ BLOCK → BlockedError
                                              └─ ⏸️ REQUIRE_APPROVAL → Slack/email
                                                  human clicks Approve
                                                  → core wakes the future
                                                  → original function runs

📜 Policies

Policies live in ./policies/ (override with TORAN_POLICY_DIR). Each *.yaml file is one policy. A policy has:

name: my-policy                       # required, unique
description: ...                      # optional
priority: 0                           # higher wins when policies overlap
default_action: ALLOW                 # what to do if nothing matches
rules:
  - name: ...                         # required
    tool:                             # required
      exact: send_email               #   string OR
      glob: "send_*"                  #   glob OR
      regex: "^db_.*$"                #   regex
    conditions:                       # optional; all must match
      - key: amount
        op: gt                        # eq, ne, contains, starts_with, ends_with,
                                       # regex, gt, lt, gte, lte, in, not_in, exists
        value: 1000
    action: REQUIRE_APPROVAL          # ALLOW | BLOCK | REQUIRE_APPROVAL
    timeout_secs: 300                 # only for REQUIRE_APPROVAL
    risk_score: 80                    # 0-100

The bundled example policies cover common use cases:

  • 📧 email-guardian.yaml — wire transfer, spam filter, external TLD.
  • 🗄️ database-guardian.yaml — DROP/DELETE/TRUNCATE protection.
  • 💰 financial-guardian.yaml — large-amount transfer approval.
  • 🔹 minimal.yaml — single rule for the quickstart.
  • 🟢 allow-all.yaml — permissive fallback (priority: -10).

validate checks syntax and structure:

./toran validate
# ✓ default action: Allow
# ✓ policy `email-guardian` (5 rules)
# ✓ policy `database-guardian` (4 rules)
# ✓ policy `financial-guardian` (3 rules)
# ✓ policy `allow-everything` (1 rules)
# ✓ policy `minimal` (1 rules)

🐍 The Python SDK

from toran import gate, configure, BlockedError, DeniedError, TimeoutError

configure(
    socket_path="/var/run/toran.sock",
    agent_id="prod-agent-7",
    fail_open=False,            # raise on connection failure (safer)
)

@gate(policy="email-guardian", timeout_secs=120)
def send_email(to, subject, body):
    return mailgun.send(to, subject, body)

@gate()
async def run_agent():
    # The decorator works on async functions too. Awaiting the call
    # triggers the gate; the event loop is never blocked.
    return await some_async_tool()

🔌 Framework integrations

from toran.integrations import (
    ToranTool, wrap_crewai_tool, wrap_pydantic_ai_tool, wrap_autogen_function,
)

# 🦜 LangChain
from langchain.tools import MoveFileTool
safe_move = ToranTool(MoveFileTool(), policy="filesystem-guardian")

# 🚣 CrewAI
safe_tool = wrap_crewai_tool(crewai_tool_instance)

# 🧩 Pydantic AI
@pydantic_ai.tool
@wrap_pydantic_ai_tool
def my_tool(ctx, x: int) -> int: ...

# 🤖 AutoGen
agent.register_function("send_email", wrap_autogen_function(send_email))

⚠️ Exceptions

Exception When
BlockedError The policy forbids this call. Catch and try an alternative.
DeniedError A human reviewer denied. Treat as permanent failure.
TimeoutError No one answered in time. Retry or escalate.
ToranConnectionError The Rust core is not running. Fall back to safe mode.
ConfigurationError Mis-configuration. Check toran.configure(...).

🖥️ The web dashboard

Open http://127.0.0.1:7878 after starting the core. The dashboard is a single-page app served by the core (no Node.js, no build step). It provides:

  • Approval queue — live list of pending requests, click to approve or deny.
  • 📋 Audit log — every decision, filter by function or agent.
  • 📜 Policy browser — read-only YAML viewer with syntax highlighting.
  • 📊 Health / metrics — uptime, total decisions, average eval latency, Prometheus-format /api/metrics.

The HTML/JS/CSS are embedded into the binary at compile time via include_str!. No static files, no asset pipeline.


🔔 Notifications (Slack, webhooks)

# Slack incoming webhook
export TORAN_SLACK_WEBHOOK="https://hooks.slack.com/services/..."

# Generic HMAC-signed webhook
export TORAN_GENERIC_WEBHOOK="https://my-app.example.com/toran"

When a function hits a REQUIRE_APPROVAL rule, the dispatcher:

  1. Logs the request to stdout (console adapter, always on).
  2. Posts a Block-Kit message to Slack (if configured).
  3. POSTs the full event JSON to your webhook (if configured).
  4. Writes a row to the audit log.

The webhook payload includes toran_signature (HMAC-SHA256 of the body using TORAN_HMAC_SECRET). Verify it on the receiver.

To resolve from the webhook receiver, POST:

curl -X POST http://127.0.0.1:7878/webhooks/toran \
  -H 'content-type: application/json' \
  -d '{"approval_id":"...","decision":"approve","token":"<notify_token>","resolved_by":"alice"}'

🏗️ Architecture

┌─────────────────────────────────────────────────────────────┐
│  USER'S PYTHON AGENT CODE                                   │
│  from toran import gate                                     │
│  @gate()                                                    │
│  def my_action(...): ...                                    │
└──────────────────┬──────────────────────────────────────────┘
                   │ JSON line over Unix socket
                   ▼
┌─────────────────────────────────────────────────────────────┐
│  PYTHON SDK (pure Python)                                   │
│  - Decorator intercepts call                                │
│  - Snapshots args (JSON), context (agent_id, session_id)    │
│  - Calls client.evaluate()                                  │
│  - On REQUIRE_APPROVAL: client.wait_for_approval()          │
└──────────────────┬──────────────────────────────────────────┘
                   │ AF_UNIX, SOCK_STREAM
                   ▼
┌─────────────────────────────────────────────────────────────┐
│  RUST CORE (single binary)                                  │
│  ┌──────────────┐ ┌──────────────┐ ┌──────────────┐         │
│  │ Policy       │ │ Evaluator    │ │ SQLite       │         │
│  │ Loader       │ │ (compiled)   │ │ State        │         │
│  │ + file watch │ │ sub-ms       │ │ WAL mode     │         │
│  └──────────────┘ └──────────────┘ └──────────────┘         │
│  ┌──────────────┐ ┌──────────────┐                          │
│  │ Notification │ │ Metrics      │                          │
│  │ Dispatcher   │ │ (Prometheus) │                          │
│  └──────────────┘ └──────────────┘                          │
└──────────────────┬──────────────────────────────────────────┘
                   │ HTTP (axum)
                   ▼
┌─────────────────────────────────────────────────────────────┐
│  DASHBOARD  +  WEBHOOK  RECEIVERS  +  EXTERNAL SYSTEMS      │
└─────────────────────────────────────────────────────────────┘

The Rust core is a single static binary of 5.8 MB. It uses no external services and can run on a Raspberry Pi. The evaluator does ~100 ns of work per rule; theoretical max throughput is on the order of millions of evaluations per second, bounded in practice by per-connection Tokio task parallelism.


📂 Project layout

toran/
├── src/                    # Rust source (~2,600 lines, 17 files)
│   ├── main.rs             # entry point
│   ├── lib.rs              # library exports
│   ├── config.rs           # config loader (env + TOML)
│   ├── cli.rs              # clap subcommands
│   ├── policy/             # YAML schema, loader, compiler, evaluator, validator
│   ├── state/              # SQLite + memory state managers
│   ├── server.rs           # Unix socket server (Python SDK)
│   ├── protocol.rs         # wire format
│   ├── api/                # axum REST API + embedded dashboard
│   ├── notification/       # dispatcher + Slack/webhook/console
│   ├── security.rs         # HMAC, tokens, chain hashing
│   └── metrics.rs          # Prometheus exporter
├── assets/                 # dashboard CSS/JS (embedded)
├── policies/               # example YAML policies
├── sdk/                    # Python SDK
│   ├── toran/              # package
│   │   ├── __init__.py
│   │   ├── core.py         # @gate decorator
│   │   ├── client.py       # socket client
│   │   ├── config.py
│   │   ├── exceptions.py
│   │   └── integrations.py
│   ├── tests/              # pytest
│   └── examples/           # minimal, langchain, custom
├── benches/                # Criterion benchmarks
├── tests/                  # integration + e2e tests
└── specs/                  # design specs (15 markdown files)

🔧 Configuration

Toran reads from environment variables (and optionally a TOML file passed via --config).

Variable Default Description
TORAN_SOCKET_PATH /tmp/toran.sock Unix socket for the Python SDK.
TORAN_API_BIND 127.0.0.1:7878 HTTP address for the dashboard and webhooks.
TORAN_POLICY_DIR ./policies Directory of YAML policies (hot-reloaded).
TORAN_DATABASE_PATH ./toran.db SQLite database file (WAL mode).
TORAN_DEFAULT_ACTION ALLOW What to return when no rule matches.
TORAN_MAX_CONNECTIONS 10000 Concurrent socket connections.
TORAN_MAX_SUSPENDED 10000 Concurrent pending approvals.
TORAN_DEFAULT_TIMEOUT 300 Seconds before a pending approval times out.
TORAN_HMAC_SECRET change-me-in-production Secret for webhook signatures.
TORAN_LOG_LEVEL info tracing filter (debug, info, warn, error).
TORAN_SLACK_WEBHOOK (none) Slack incoming-webhook URL.
TORAN_GENERIC_WEBHOOK (none) Generic HMAC-signed webhook.

⚠️ fail_open on the Python side defaults to false. If the core is unreachable, calls raise ToranConnectionError so the agent can fall back to safe mode.


🚢 Deployment

📦 Single binary (default)

./toran start

Everything runs in one process. SQLite for state, Unix socket for the SDK, HTTP for the dashboard.

🐳 Docker

A multi-stage Dockerfile (distroless runtime image) is included:

docker build -t toran .
docker run -p 7878:7878 -v "$PWD/data:/data" toran

The container binds the API to 0.0.0.0:7878 and persists state in the /data volume.

🌐 Reverse proxy (TLS)

location / {
    proxy_pass http://127.0.0.1:7878;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

🔐 Security model

  • 🚫 No code execution in policies. Conditions are a fixed operator set: eq, ne, contains, starts_with, ends_with, regex, gt, lt, gte, lte, in, not_in, exists. No eval.
  • 🎲 Random notify tokens. 16 bytes of CSPRNG, hex-encoded, attached to every approval record. The token is the only credential needed to resolve the approval.
  • ⏱️ Constant-time comparison. Tokens are compared with a constant-time check, not ==.
  • ✍️ HMAC signatures. Webhook payloads carry an HMAC-SHA256 signature in the body for the receiver to verify.
  • 📝 Append-only audit log. Records are only inserted. The schema has no UPDATE statement against existing audit rows.
  • 🔗 Tamper-evident chaining. Each audit row can optionally chain SHA-256(prev_hash || row_json).
  • 🔒 Socket permissions. The Unix socket is created with mode 0660. The default DB file is 0600.

See specs/11-SECURITY.md for the full threat model and mitigations.


⌨️ CLI reference

$ toran --help
Runtime human-approval gatekeeper for AI agents

Usage: toran [OPTIONS] <COMMAND>

Commands:
  start     Start the socket server and REST API (default mode)
  validate  Validate all YAML policy files without starting the server
  status    Print a one-line status summary
  list      List pending or recent approvals
  approve   Approve a pending approval by id
  deny      Deny a pending approval by id
  help      Print this message or the help of the given subcommand(s)

Options:
      --config <CONFIG>  Path to a TOML config file (overrides env)
  -h, --help             Print help
  -V, --version          Print version

Examples:

toran validate
toran list --status pending --limit 20
toran approve <id> --by alice --token <notify_token>
toran deny <id> --by alice
toran status

🧪 Testing & benchmarks

# 🦀 Rust — 25 tests (10 policy, 7 state, 3 e2e, plus unit)
cargo test --all-features

# 🐍 Python — 6 decorator tests
cd sdk && python3 -m pytest -v

# 📈 Criterion benchmark (policy evaluation)
cargo bench --bench policy_eval

CI runs formatting (cargo fmt --check), linting (cargo clippy -D warnings), and the full test suite on every push and PR — see .github/workflows/ci.yml.


🤝 Contributing

See CONTRIBUTING.md for the full guide. The short version:

  • cargo fmt before every commit.
  • cargo clippy -- -D warnings must pass.
  • Add tests for new features.
  • Update CHANGELOG.md.
  • Use conventional commits: feat:, fix:, docs:, refactor:, test:, security:.

Dev loop:

cargo watch -x check -x clippy -x test
cargo run -- start
# in another shell
TORAN_SOCKET_PATH=/tmp/toran.sock python3 sdk/examples/minimal.py

📄 License

MIT © Zyferon


Maintained by Zyferon · Report an issue

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

toran_sdk-0.1.1.tar.gz (23.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

toran_sdk-0.1.1-py3-none-any.whl (18.2 kB view details)

Uploaded Python 3

File details

Details for the file toran_sdk-0.1.1.tar.gz.

File metadata

  • Download URL: toran_sdk-0.1.1.tar.gz
  • Upload date:
  • Size: 23.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for toran_sdk-0.1.1.tar.gz
Algorithm Hash digest
SHA256 1f43663421f988982c932672ef6fa3c509f787f74efe05b9425fd4409677efeb
MD5 a292deb510c0b56e66e4d4352426334b
BLAKE2b-256 5912a0b493f60e28d9731d3427064cbdef2a92a7891b8d55147f06c77ab1dc40

See more details on using hashes here.

Provenance

The following attestation bundles were made for toran_sdk-0.1.1.tar.gz:

Publisher: publish.yml on Zyferon/toran

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file toran_sdk-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: toran_sdk-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 18.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for toran_sdk-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 2c7d7d6a12763b6890dc6fbf5f507fc01204e0811d48b809ea5d60df9abdd750
MD5 3f5184fde29fc51a5da75f16ca1f9adc
BLAKE2b-256 615eca5d2c933cecb2cc9e18e2ead013474f4082b215d23410b51922f728d110

See more details on using hashes here.

Provenance

The following attestation bundles were made for toran_sdk-0.1.1-py3-none-any.whl:

Publisher: publish.yml on Zyferon/toran

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page