Skip to main content

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 native interrupt(). 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 (stdlib http.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; args aren'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-analyzer is 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 Backend interface (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 terminal input() 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

approval_gate-0.1.0.tar.gz (54.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

approval_gate-0.1.0-py3-none-any.whl (45.3 kB view details)

Uploaded Python 3

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

Hashes for approval_gate-0.1.0.tar.gz
Algorithm Hash digest
SHA256 11c7df0d44ccfb0b21c88e4f46d7d6b615da4d6ab5f918a660dc5b37443c6ed7
MD5 4bb618f75cea9ad9f9e2ee49cf141edd
BLAKE2b-256 0d4c739c355dbba800f40402c5739972407714e348e03fe6f437c91cb382b794

See more details on using hashes here.

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

Hashes for approval_gate-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d273f0646a4699aeb44a3ad33e551f71a2a362ea9660a10d65a256e59a8ae282
MD5 253fe89a6065c7d47dd75809939e359a
BLAKE2b-256 30bfa6727de56822443fec70ecd27a83558f63b8c040e6fea55a3b8a5f2d35f1

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page