Skip to content

Repository files navigation

πŸ›• 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

Install the SDK:

pip install toran-sdk

πŸ“‘ 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

pip install toran-sdk
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

Install from PyPI:

pip install toran-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

Releases

Packages

Contributors

Languages