approval-gate
A drop-in approval gate and audit trail for AI agents — works with LangGraph, or with no framework at all.
Before your agent sends an email, deletes a record, or calls an API it
shouldn't be calling unsupervised, approval-gate pauses it, shows a
human exactly what it's about to do (with any sensitive data flagged),
and waits for approve / edit / reject — with a permanent record of
what happened.
Status: early / seeking feedback. The core (audit log, PII scanning, approve/edit/reject loop), the framework-agnostic backend abstraction, four approval channels (browser, webhook, email, Slack), and per-action policies are all built and tested. A hosted dashboard is the only thing left on the roadmap — see Roadmap.
This README is the pitch and a quick tour. For a task-oriented walkthrough — which channel to pick, full setup for each, policies, troubleshooting — see USER_GUIDE.md. Running any of the network-facing backends (Email, Slack, Webhook) somewhere real? Read SECURITY.md first — secrets handling and why they need a reverse proxy in front.
agent proposes an action
│
▼
┌─────────────────┐ flags emails, phone numbers, card numbers,
│ scan for PII / │ ── API keys, etc. in the proposed action
│ secrets │
└────────┬─────────┘
▼
┌─────────────────┐ policy decides: auto-approve/reject, or
│ PAUSE & ASK │ ── pause and ask a human (browser, terminal,
│ a human │ LangGraph interrupt() -- pluggable)
└────────┬─────────┘
▼
approve / edit / reject ──→ action runs (or doesn't) ──→ logged forever
Why this exists
LangGraph already gives you interrupt() for human-in-the-loop control.
That part's free and built in. What's missing is everything around
it: a place to log what was proposed, a way to flag sensitive data
before a human sees it (or before it sits in a log file), and a
consistent pattern so every tool in your codebase pauses the same way
instead of every team hand-rolling it slightly differently.
This is that missing layer. Nothing more.
What this is not: a full observability platform (see Langfuse / LangSmith for that), a vulnerability scanner for MCP servers, or an enterprise compliance suite. If you need those, go get those — this is meant to sit alongside them, not replace them.
Install
pip install approval-gate
# using LangGraph? add the extra:
pip install "approval-gate[langgraph]"
# optional, for richer name/location PII detection beyond the built-in
# regex checks:
pip install presidio-analyzer && python -m spacy download en_core_web_sm
The core package (ApprovalGate, the audit log, PII scanning,
BlockingBackend) has no required dependencies — LangGraph is only
needed if you use LangGraphBackend.
60-second example
from approval_gate import ApprovalGate
gate = ApprovalGate(db_path="audit.db")
def send_email(to: str, subject: str, body: str) -> str:
decision = gate.request_approval(
action_name="send_email",
args={"to": to, "subject": subject, "body": body},
risk="medium",
)
if not decision.approved:
return f"BLOCKED: {decision.reason}"
result = really_send_email(**decision.args) # decision.args reflects any edits
gate.log_result(decision.audit_id, result)
return result
Backends: how the pause actually happens
ApprovalGate doesn't know or care how a human's decision gets back
to it — that's the job of a Backend. Pick the one that matches your
stack:
-
LangGraphBackend(default) — pauses via LangGraph's nativeinterrupt(). Drive the graph from outside, resuming whenever it pauses:from langgraph.types import Command result = graph.invoke(initial_state, config) while "__interrupt__" in result: pending = result["__interrupt__"][0].value # action, args, pii_findings, risk decision = ask_a_human_somehow(pending) # {"decision": "approve"|"reject"|"edit", ...} result = graph.invoke(Command(resume=decision), config)
-
BlockingBackend— no framework at all. Works from a plain Python tool-calling loop, a script, a notebook — anywhere you can just block the current thread waiting on a decision:from approval_gate import ApprovalGate from approval_gate.backends import BlockingBackend def ask_a_human(pending: dict) -> dict: print(pending["action"], pending["args"]) return {"decision": "approve", "by": "amit"} gate = ApprovalGate(db_path="audit.db", backend=BlockingBackend(ask_a_human))
-
WebBackend— a real review inbox in a browser tab instead of a blocking terminal prompt. Zero new dependencies (stdlibhttp.server); runs locally and polls for pending actions:from approval_gate import ApprovalGate from approval_gate.backends import WebBackend backend = WebBackend(port=8642) gate = ApprovalGate(db_path="audit.db", backend=backend) print(f"Review inbox running at {backend.url}") # ... call gate.request_approval(...) from your agent as usual ... backend.shutdown()
Multiple pending actions queue up and are all shown at once, each decided independently. Works with any other backend's caller code unchanged — combine it with LangGraph or plain Python the same way as
BlockingBackend. -
WebhookBackend— bring your own system. POSTs the pending action to a URL you configure; your system POSTs the decision back. For teams with their own ticketing/admin tooling who don't want to adopt Slack or email specifically for this:from approval_gate.backends import WebhookBackend backend = WebhookBackend(notify_url="https://internal-tools.example.com/incoming", port=8643) gate = ApprovalGate(db_path="audit.db", backend=backend) # your system calls back POST {backend.url}/decide with the decision
-
EmailBackend— signed one-click approve/reject links, no browser tab or login required. Good for slower-moving approvals:from approval_gate.backends import EmailBackend backend = EmailBackend( smtp_host="smtp.example.com", smtp_port=587, smtp_user="bot@example.com", smtp_password="...", from_addr="approval-gate@example.com", to_addr="reviewer@example.com", secret="a-random-string-you-generate-once", public_base_url="https://approvals.example.com", ) gate = ApprovalGate(db_path="audit.db", backend=backend)
Links are HMAC-signed so they can't be edited into approving a different action;
argsaren't editable from an email link, only approve/reject. -
SlackBackend— interactive Approve/Reject buttons right in a Slack message, resolved without leaving Slack:from approval_gate.backends import SlackBackend backend = SlackBackend( bot_token="xoxb-...", signing_secret="...", channel="#approvals", port=8645, ) gate = ApprovalGate(db_path="audit.db", backend=backend)
Every interaction is verified against Slack's own request-signing scheme before anything in the payload is trusted. Needs a public Request URL Slack can reach (a tunnel in development, real ingress in production) — see
approval_gate/backends/slack.py's docstring for the one-time Slack app setup.
Writing a new backend means implementing one method,
wait_for_decision(pending) -> dict (see approval_gate/backends/base.py).
Any backend where the decision arrives asynchronously (a click, a
webhook, a reply) can build on _PendingQueueBackend instead of
re-solving "wait on a thread, resolve it from an HTTP handler" from
scratch — see BACKEND_TEMPLATE.md for the
full pattern and a contribution checklist.
Notifications
A review sitting in a browser tab nobody's looking at doesn't help.
Pass notifier= to WebBackend to get pinged the instant something
needs review:
from approval_gate.backends import WebBackend
from approval_gate.notifiers import SlackNotifier
backend = WebBackend(
port=8642,
notifier=SlackNotifier(webhook_url="https://hooks.slack.com/services/..."),
)
SlackNotifier is built in (stdlib urllib, no slack-sdk
dependency) and posts the action name, risk level, a flag if sensitive
data was detected, and a link straight into the review inbox. Any
callable matching (pending: dict, review_url: str) -> None works as
a notifier — write your own for email, PagerDuty, a custom webhook,
whatever routes to your team. A broken notifier is caught and logged,
never allowed to block or fail the approval flow itself.
Policies: not every action needs a human
Pausing for a human on every call doesn't scale past a handful of
action types before reviewers start rubber-stamping everything, which
defeats the point. Pass policy= to auto-approve, auto-reject, or
route actions before a human is ever bothered:
from approval_gate import ApprovalGate
from approval_gate.policy import Rule, RulePolicy
policy = RulePolicy([
Rule(risk="low", has_pii=False, auto_approve=True, name="auto-low-risk"),
Rule(action_prefix="delete_", route_to="oncall-reviewer"),
])
gate = ApprovalGate(db_path="audit.db", policy=policy)
Rules are evaluated in order; the first match wins. auto_approve /
auto_reject skip the human step entirely — nothing shows up in a
review inbox, no notifier fires, but the decision is still written to
the audit log with decided_by="policy:<rule-name>" so it stays fully
traceable. route_to doesn't auto-decide; it tags the pending payload
so the backend/notifier can direct it to the right reviewer, and the
action still goes through normal human review.
For anything a declarative rule can't express, pass a plain callable
instead — (pending: dict) -> Optional[dict], same escape hatch as
Backend/Notifier. A policy must be a pure function of pending: it
can re-run on a LangGraph resume-replay, same as everything else before
a pause (see the note on LangGraph internals below).
Every example below runs with no API key and no real third-party credentials -- each fakes out only the third-party call (SMTP, Slack's API) while the actual approval-gate mechanics run for real:
git clone <repo>
cd approval-gate
pip install -r requirements.txt
python examples/email_agent_demo.py # LangGraph, terminal review
python examples/plain_python_demo.py # no framework, terminal review
python examples/web_inbox_demo.py # browser review inbox
python examples/webhook_demo.py # bring-your-own-system channel
python examples/email_demo.py # signed approve/reject email links
python examples/slack_demo.py # interactive Slack buttons
python examples/notifier_demo.py # + Slack ping into the web inbox
python examples/policy_demo.py # + auto-approve / routing
python examples/seed_inbox_demo.py # fully populated inbox (6 scenarios at once)
python examples/view_audit_log.py # see what got logged
How sensitive-data scanning works
Every proposed action's arguments are scanned before being shown to a reviewer or written to the log:
- Always on, zero setup: regex checks for emails, phone numbers, credit card numbers (Luhn-validated), SSN-shaped numbers, and API-key-shaped strings (OpenAI, AWS, GitHub, Slack, Google patterns).
- Optional, richer: if
presidio-analyzeris installed, it also runs Microsoft Presidio for NLP-based name/location/organization detection.
Findings are masked before they're logged — you get pr***...om,
not the raw email address, sitting in your audit database.
A note on how LangGraphBackend is built on LangGraph internals
When a node calling interrupt() is resumed, LangGraph re-runs that
node function from the top — so any code before the interrupt() call
executes again. ApprovalGate handles this by giving every pending
action a deterministic ID (a hash of thread + action + args) and
upserting instead of inserting, so a resume-replay updates the same
row rather than creating a duplicate. This is unconditional — it
happens regardless of which backend you use — but it's specifically
LangGraph's replay-on-resume behavior that makes it necessary. If
you're extending this code, that's the one piece of LangGraph-specific
subtlety to keep in mind.
Roadmap
- Framework-agnostic core — pluggable
Backendinterface (LangGraphBackend,BlockingBackend), so approval-gate isn't tied to one agent framework. - Local web review UI (
WebBackend) — approve/edit/reject from a browser instead of a blocking terminalinput()prompt. - Notification when something's waiting for review (
SlackNotifier, pluggable for anything else — email, PagerDuty, custom webhooks). - Per-action policies (
RulePolicy) — auto-approve low-risk, always escalate deletes, route by action type to a specific reviewer. - Multiple approval channels — browser (
WebBackend), bring-your-own-system (WebhookBackend), signed email links (EmailBackend), interactive Slack buttons (SlackBackend). More channels (Teams, PagerDuty, SMS) are a community surface now, not a backlog — see BACKEND_TEMPLATE.md. - Hosted dashboard (team accounts, longer-retention Postgres-backed log) — paid tier, self-hosted core stays free and open forever. This is the only thing left.
Contributions welcome — see CONTRIBUTING.md for setup and where things live, and BACKEND_TEMPLATE.md if you want to add another approval channel — that's the highest-value contribution this project can accept right now. Open an issue if you want to discuss an approach before sending a PR.
License
MIT. Use it, modify it, ship it in commercial products. If it's useful to you, a GitHub star helps other people find it — that's the entire marketing budget for this project.
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 approval_gate-0.1.0.tar.gz.
File metadata
- Download URL: approval_gate-0.1.0.tar.gz
- Upload date:
- Size: 54.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
11c7df0d44ccfb0b21c88e4f46d7d6b615da4d6ab5f918a660dc5b37443c6ed7
|
|
| MD5 |
4bb618f75cea9ad9f9e2ee49cf141edd
|
|
| BLAKE2b-256 |
0d4c739c355dbba800f40402c5739972407714e348e03fe6f437c91cb382b794
|
File details
Details for the file approval_gate-0.1.0-py3-none-any.whl.
File metadata
- Download URL: approval_gate-0.1.0-py3-none-any.whl
- Upload date:
- Size: 45.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d273f0646a4699aeb44a3ad33e551f71a2a362ea9660a10d65a256e59a8ae282
|
|
| MD5 |
253fe89a6065c7d47dd75809939e359a
|
|
| BLAKE2b-256 |
30bfa6727de56822443fec70ecd27a83558f63b8c040e6fea55a3b8a5f2d35f1
|