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.
Metadata
Release files for approval-gate 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| approval_gate-0.1.0.tar.gz | 54.8 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| approval_gate-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 100.1 kB
Release files / approval_gate-0.1.0.tar.gz
| Download URL | approval_gate-0.1.0.tar.gz |
|---|---|
| Size | 54.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
11c7df0d44ccfb0b21c88e4f46d7d6b615da4d6ab5f918a660dc5b37443c6ed7
|
|
BLAKE2b-256 checksum How to use checksums |
0d4c739c355dbba800f40402c5739972407714e348e03fe6f437c91cb382b794
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Release files / approval_gate-0.1.0-py3-none-any.whl
| Download URL | approval_gate-0.1.0-py3-none-any.whl |
|---|---|
| Size | 45.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d273f0646a4699aeb44a3ad33e551f71a2a362ea9660a10d65a256e59a8ae282
|
|
BLAKE2b-256 checksum How to use checksums |
30bfa6727de56822443fec70ecd27a83558f63b8c040e6fea55a3b8a5f2d35f1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.5
|
Security for approval-gate 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.