#!/usr/bin/env python3 """approval-gate: a yes/no button in Telegram before your AI agent does something. approval-gate.py request "description" [--command "exact cmd"] [--ttl 86400] [--wait] [--json] approval-gate.py wait [--timeout 600] exit 0 = approved, 2 = denied, 3 = expired/timeout approval-gate.py status [--json] approval-gate.py list [--pending] `request` stores a pending approval as a JSON file and sends you a Telegram message with two buttons. Your webhook handler receives the button press (a callback_query), checks who pressed it and writes "approved" or "denied" into that same file. `wait` blocks until that happens. After exit 0, run exactly the command from the file. Never let the agent rephrase it. Configuration (environment): TG_BOT_TOKEN token from @BotFather (required) TG_CHAT_ID your own chat id, where the question goes (required) APPROVAL_STORE folder for the JSON files (default ~/.local/share/approvals) TG_API Telegram API base URL (default https://api.telegram.org; handy for tests) Python standard library only. From https://www.invoker.nl (MIT license). """ import argparse import html import json import os import secrets import sys import time import urllib.request STORE = os.path.expanduser(os.environ.get("APPROVAL_STORE", "~/.local/share/approvals")) TG_API = os.environ.get("TG_API", "https://api.telegram.org").rstrip("/") # Upper limit for an inline `request --wait`; the TTL itself may be much longer. WAIT_CAP = 900 def _tg(method, payload): tok = os.environ.get("TG_BOT_TOKEN") or sys.exit("TG_BOT_TOKEN is not set") req = urllib.request.Request( f"{TG_API}/bot{tok}/{method}", data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=15) as r: return json.load(r) def _path(aid): return os.path.join(STORE, f"{aid}.json") def _load(aid): try: with open(_path(aid)) as fh: return json.load(fh) except OSError: sys.exit(f"unknown approval: {aid}") def _save(a): os.makedirs(STORE, exist_ok=True) tmp = _path(a["id"]) + ".tmp" with open(tmp, "w") as fh: json.dump(a, fh, ensure_ascii=False, indent=2) os.replace(tmp, _path(a["id"])) # atomic: a reader never sees half a file def _effective_status(a): if a["status"] == "pending" and time.time() > a["expires_at"]: # Write the transition down as well. Otherwise the file keeps saying "pending" # and anyone reading the JSON directly treats an expired request as open. a["status"] = "expired" try: _save(a) except OSError: pass return "expired" return a["status"] def cmd_request(args): chat = os.environ.get("TG_CHAT_ID") or sys.exit("TG_CHAT_ID is not set") aid = secrets.token_hex(5) a = { "id": aid, "description": args.description, "command": args.command or None, "status": "pending", "created_at": time.time(), "expires_at": time.time() + args.ttl, "requested_by": args.source, "decided_at": None, "decided_by": None, "chat_id": chat, "message_id": None, } e = html.escape until = time.strftime("%a %d-%m %H:%M", time.localtime(a["expires_at"])) text = (f"๐Ÿ” Approval needed\n\n{e(args.description)}\n" + (f"\ncommand:\n{e(args.command)}\n" if args.command else "") + f"\n(id {aid} ยท from {e(args.source)} ยท valid until {until})") # HTML instead of Markdown: with Markdown one underscore in the description breaks the send. resp = _tg("sendMessage", { "chat_id": chat, "text": text, "parse_mode": "HTML", "reply_markup": {"inline_keyboard": [[ {"text": "โœ… Yes, run it", "callback_data": f"appr|{aid}|yes"}, {"text": "โŒ No", "callback_data": f"appr|{aid}|no"}, ]]}, }) a["message_id"] = resp.get("result", {}).get("message_id") _save(a) print(json.dumps({"id": aid}) if args.json else aid) if args.wait: # Do not wait for the full TTL: it is long on purpose so the question survives a # night, but an agent run should not hang on it. The request stays valid until the TTL. return cmd_wait(argparse.Namespace(id=aid, timeout=min(args.ttl, WAIT_CAP), json=args.json)) return 0 def cmd_wait(args): deadline = time.time() + args.timeout while time.time() < deadline and _effective_status(_load(args.id)) == "pending": time.sleep(3) a = _load(args.id) st = _effective_status(a) print(json.dumps({"id": a["id"], "status": st, "decided_by": a.get("decided_by")}) if getattr(args, "json", False) else st) return {"approved": 0, "denied": 2}.get(st, 3) def cmd_status(args): a = _load(args.id) a["status"] = _effective_status(a) print(json.dumps(a, ensure_ascii=False, indent=2) if args.json else f"{a['id']} {a['status']} {a['description']}") return 0 def cmd_list(args): os.makedirs(STORE, exist_ok=True) rows = [] for f in sorted(os.listdir(STORE)): if not f.endswith(".json"): continue try: with open(os.path.join(STORE, f)) as fh: a = json.load(fh) except (OSError, ValueError): continue st = _effective_status(a) if args.pending and st != "pending": continue rows.append(f"{a['id']} {st:9} {time.strftime('%d-%m %H:%M', time.localtime(a['created_at']))} {a['description'][:60]}") print("\n".join(rows) if rows else "(no approvals)") return 0 def main(): ap = argparse.ArgumentParser(prog="approval-gate") sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("request") p.add_argument("description") p.add_argument("--command", default=None, help="exact command that runs after approval (shown in the message)") # Default 24 hours: a question has to survive a night. With 1 hour, requests made at # night expired before anyone was awake; the button stayed clickable but was refused. p.add_argument("--ttl", type=int, default=86400, help="seconds valid (default 24h)") p.add_argument("--source", default="cli", help="who is asking (agent name, script)") p.add_argument("--wait", action="store_true") p.add_argument("--json", action="store_true") p.set_defaults(fn=cmd_request) p = sub.add_parser("wait") p.add_argument("id") p.add_argument("--timeout", type=int, default=600) p.add_argument("--json", action="store_true") p.set_defaults(fn=cmd_wait) p = sub.add_parser("status") p.add_argument("id") p.add_argument("--json", action="store_true") p.set_defaults(fn=cmd_status) p = sub.add_parser("list") p.add_argument("--pending", action="store_true") p.set_defaults(fn=cmd_list) args = ap.parse_args() sys.exit(args.fn(args)) if __name__ == "__main__": main()