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
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
- ✨ Why Toran
- 🚀 Quick start
- ⚙️ How it works
- 📜 Policies
- 🐍 The Python SDK
- 🖥️ The web dashboard
- 🔔 Notifications (Slack, webhooks)
- 🏗️ Architecture
- 📂 Project layout
- 🔧 Configuration
- 🚢 Deployment
- 🔐 Security model
- ⌨️ CLI reference
- 🤝 Contributing
- 📄 License
🧭 First time? Read
TOUR.mdfor 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:
- Logs the request to stdout (console adapter, always on).
- Posts a Block-Kit message to Slack (if configured).
- POSTs the full event JSON to your webhook (if configured).
- 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_openon the Python side defaults to false. If the core is unreachable, calls raiseToranConnectionErrorso 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. Noeval. - 🎲 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
UPDATEstatement 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 is0600.
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 fmtbefore every commit.cargo clippy -- -D warningsmust 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
Maintained by Zyferon · Report an issue
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1f43663421f988982c932672ef6fa3c509f787f74efe05b9425fd4409677efeb
|
|
| MD5 |
a292deb510c0b56e66e4d4352426334b
|
|
| BLAKE2b-256 |
5912a0b493f60e28d9731d3427064cbdef2a92a7891b8d55147f06c77ab1dc40
|
Provenance
The following attestation bundles were made for toran_sdk-0.1.1.tar.gz:
Publisher:
publish.yml on Zyferon/toran
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
toran_sdk-0.1.1.tar.gz -
Subject digest:
1f43663421f988982c932672ef6fa3c509f787f74efe05b9425fd4409677efeb - Sigstore transparency entry: 1753718170
- Sigstore integration time:
-
Permalink:
Zyferon/toran@69a7cc966c7b0a5e8e304c529dc32aab9b10f7ac -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/Zyferon
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@69a7cc966c7b0a5e8e304c529dc32aab9b10f7ac -
Trigger Event:
release
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2c7d7d6a12763b6890dc6fbf5f507fc01204e0811d48b809ea5d60df9abdd750
|
|
| MD5 |
3f5184fde29fc51a5da75f16ca1f9adc
|
|
| BLAKE2b-256 |
615eca5d2c933cecb2cc9e18e2ead013474f4082b215d23410b51922f728d110
|
Provenance
The following attestation bundles were made for toran_sdk-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on Zyferon/toran
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
toran_sdk-0.1.1-py3-none-any.whl -
Subject digest:
2c7d7d6a12763b6890dc6fbf5f507fc01204e0811d48b809ea5d60df9abdd750 - Sigstore transparency entry: 1753718184
- Sigstore integration time:
-
Permalink:
Zyferon/toran@69a7cc966c7b0a5e8e304c529dc32aab9b10f7ac -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/Zyferon
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@69a7cc966c7b0a5e8e304c529dc32aab9b10f7ac -
Trigger Event:
release
-
Statement type: