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 sentInstall the SDK:
pip install toran-sdk- β¨ 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.
| πͺΆ 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 |
git clone https://github.com/Zyferon/toran.git
cd toran
cargo build --releaseThe single static binary is at target/release/toran.
# 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./target/release/toran start
# β 2026-β¦ toran socket server listening socket=/tmp/toran.sock
# β 2026-β¦ toran api listening addr=127.0.0.1:7878Open http://127.0.0.1:7878 for the dashboard.
pip install toran-sdkfrom 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!!!", "...") # β BLOCKToran 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 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-100The 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)Install from PyPI:
pip install toran-sdkfrom 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()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))| 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(...). |
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.
# 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"}'βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 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.
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)
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.
./toran startEverything runs in one process. SQLite for state, Unix socket for the SDK, HTTP for the dashboard.
A multi-stage Dockerfile (distroless runtime image) is included:
docker build -t toran .
docker run -p 7878:7878 -v "$PWD/data:/data" toranThe container binds the API to 0.0.0.0:7878 and persists state in the
/data volume.
location / {
proxy_pass http://127.0.0.1:7878;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}- π« 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.
$ 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# π¦ 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_evalCI 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.
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.pyMaintained by Zyferon Β· Report an issue