Interactive Callback Authorization
Lock interactive buttons (tool approvals, menus) to specific users so unauthorized clickers in shared chats cannot resolve another user's approval
PraisonAI bots can pin every interactive button (approve, deny, menu choice) to a specific set of users so other participants in the same chat cannot resolve it.
from praisonaiagents import Agent
from praisonai.bots import Bot
agent = Agent(name="Ops", instructions="Run approved changes only", approval=True)
bot = Bot("slack", agent=agent)The user clicks Approve in a shared channel; only the requester and owners can resolve that callback.
Tool-approval Allow/Deny buttons ride a distinct interactive route (cmd:/approve <approval_id> <decision>) that bypasses the generic command policy — authorisation is enforced by the backend’s allowed_actors, not the per-command allow-list. Unknown decision values are rejected before hitting the backend. See Telegram Durable Approval.
Quick Start
- Default — your bot is already protected
If you run a Telegram, Slack, or Discord bot using the built-in
_presentation_approvalwrapper, approvals are already pinned to the requester plus any configured owner/admins. No extra code required.python from praisonaiagents import Agent from praisonai.bots import Bot agent = Agent( name="assistant", instructions="You are a helpful assistant.", ) bot = Bot("telegram", agent=agent) bot.run()When the bot sends an approval prompt, only the user who triggered the tool call (plus
owner_user_idandadmin_usersyou configured) can click Approve or Deny. Other users in the same group chat are silently rejected. - Restrict approvals to a specific user
Pass
allowed_actorswhen callingrequest_approvalfrom a custom bot adapter to bind a single approval to one or more actors:python from praisonai.bots._presentation_approval import PresentationApprovalHandler handler = PresentationApprovalHandler() result = await handler.request_approval( tool_name="bash", arguments={"command": "rm -rf /tmp/cache"}, allowed_actors=["telegram:12345"], # only this user may resolve channel_send_func=send_func, target=chat_id, ) if result["approved"]: print("Approved by the authorized user")An unauthorized click leaves the approval pending — the legitimate actor can still resolve it.
- Register a custom interactive button with authorization
Protect any interactive namespace (menus, polls, custom buttons) using an
authorizecallback:python from praisonaiagents.bots.interactive import InteractiveRegistry, InteractiveAuthorizer registry = InteractiveRegistry() OWNER_IDS = {"telegram:99999", "slack:U01234"} def only_owners(ctx) -> bool: return ctx.user_id in OWNER_IDS async def my_menu_handler(ctx): return f"Selected: {ctx.platform_data.get('value')}" registry.register("menu", my_menu_handler, authorize=only_owners)If
authorizereturnsFalseor raises,dispatch()returnsFalseand the handler is never called. Fail-closed by design.
How It Works
Two layers of protection work together: the registry layer (any interactive callback) and the approval-wrapper layer (tool approvals specifically).
State of an approval ID:
A resolved approval ID is single-use — any second callback for it is a no-op, preventing replay attacks.
Which authorization shape do I need?
API Reference
| Symbol | Surface | Module |
|---|---|---|
InteractiveAuthorizer | type alias Callable[[InteractiveContext], bool] | praisonaiagents.bots.interactive |
InteractiveRegistry.register(..., authorize=) | registers handler + optional authorizer | praisonaiagents.bots.interactive |
request_approval(..., allowed_actors=) | binds approval to actor set | praisonai.bots._presentation_approval |
handle_approval_command(..., actor=) | enforces actor on resolve | praisonai.bots._presentation_approval |
is_authorized(approval_id, actor) | helper for custom adapters | praisonai.bots._presentation_approval |
audit_log (property) | copy of resolved-approval audit entries | praisonai.bots._presentation_approval |
history_limit (constructor) | bounded FIFO for _resolved_ids + _audit_log (default 1000) | praisonai.bots._presentation_approval |
Audit Log
Every resolution appends an entry to the bounded in-memory audit log. Retrieve it with the audit_log property (returns deep-copied dicts — callers cannot mutate internal state).
| Field | Type | Description | |
|---|---|---|---|
approval_id | str | Unique ID for this approval request | |
tool_name | str | Name of the tool awaiting approval | |
actor | `str \ | None` | User ID of the clicker (platform-prefixed, e.g. telegram:12345) |
decision | str | allow, deny, or always | |
approved | bool | Whether the tool was approved | |
authorized | bool | Whether the actor was in the allowed set | |
timestamp | float | Unix timestamp of the resolution |
Three categories of entries are written:
| Category | approved | authorized |
|---|---|---|
| Authorized approval | True | True |
| Authorized denial | False | True |
| Unauthorized attempt | False | False |
Timeouts also append an entry (decision "timeout", approved=False, authorized=True if no actor restriction was set).
Common Patterns
Read the audit log after a session:
from praisonai.bots._presentation_approval import PresentationApprovalHandler
handler = PresentationApprovalHandler()
# ... run your bot session ...
for entry in handler.audit_log:
print(entry["actor"], entry["decision"], entry["authorized"])Cap memory for short-lived sessions:
handler = PresentationApprovalHandler(history_limit=200)The oldest entries are evicted once the limit is exceeded (bounded FIFO). Replay protection still holds for the most recent history_limit approvals.
Check authorization without resolving:
if handler.is_authorized(approval_id, actor="telegram:12345"):
print("This user may resolve the approval")Text-keyword fallback backends
TelegramApproval, SlackApproval, and DiscordApproval are separate from the _presentation_approval wrapper documented above. They poll for text keyword replies (or inline button taps on Telegram) and have their own actor allowlist added in PR #2582.
Set allowed_approvers on the constructor (or via env var) to restrict who can resolve an approval from these backends:
from praisonaiagents import Agent
from praisonai.bots import TelegramApproval
agent = Agent(
name="ops",
instructions="Run approved changes only.",
approval=TelegramApproval(
chat_id="-100999888",
allowed_approvers=["11111", "22222"], # only these users may resolve
),
)
agent.start("Deploy the release")| Situation | Effect |
|---|---|
No allowed_approvers configured | Any responder can resolve (legacy — unsafe in shared chats) |
| Authorised user taps Approve / replies “yes” | Approval resolved (approved=True) |
| Authorised user taps Deny / replies “no” | Approval resolved (approved=False) |
| Unauthorised user taps Telegram inline button | Telegram shows “You are not authorized to approve this action.” alert; poll continues |
| Unauthorised user replies with keyword (any channel) | Warning logged; reply ignored; poll continues |
Unauthorised user’s message from a different Telegram chat with same message_id | Ignored (chat-id binding added in PR #2582) |
| Approval times out | ApprovalDecision(approved=False, reason="timed out") |
The env var equivalents are TELEGRAM_APPROVERS, SLACK_APPROVERS, and DISCORD_APPROVERS (comma-separated user IDs). See the Approval Protocol page for full configuration tables.
Out-of-band backends
HTTPApproval (local web dashboard) and WebhookApproval (POST + poll) are out-of-band — they do not run inside a chat. Since PR #5612 both accept an allowed_approvers= allowlist on the same is_authorized_actor primitive the chat backends use.
from praisonaiagents import Agent
from praisonai.bots import HTTPApproval, WebhookApproval
# Local dashboard — only Alice or Bob may click Approve
http = HTTPApproval(host="127.0.0.1", port=8899, allowed_approvers=["alice", "bob"])
# External webhook — only enterprise-dashboard-authorised approvers may resolve
webhook = WebhookApproval(
webhook_url="https://your-app.com/api/approvals",
allowed_approvers=["alice", "bob"],
)An agent-centric setup — the approval URL printed to the console carries the single-use token, so only Alice or Bob (holding that URL) can resolve it:
from praisonaiagents import Agent
from praisonai.bots import HTTPApproval
agent = Agent(
name="ops",
instructions="Only run destructive commands when a human approves.",
tools=["execute_command"],
approval=HTTPApproval(
host="127.0.0.1",
port=8899,
allowed_approvers=["alice", "bob"], # only these operators may click Approve
),
)
agent.start("Delete the old release artifacts")| Backend | Enforcement mechanism | Rejection behaviour |
|---|---|---|
HTTPApproval | Single-use secrets.token_urlsafe(32) embedded only in the private URL; both page GET and decide POST validate it (secrets.compare_digest); plus the decision body’s approver must be in the allowlist | 403 on missing/invalid token; 403 on unauthorised approver; approval stays pending |
WebhookApproval | The responder’s approver field (immediate POST response and polled status) is checked against the allowlist | Decision is dropped silently (warning logged); polling continues until a legitimate approver resolves it or the request times out |
The operator-facing flow for HTTPApproval — an intruder without the token is locked out:
Env-var fallbacks mirror the chat backends:
export HTTP_APPROVERS=alice,bob
export WEBHOOK_APPROVERS=alice,bobBest Practices
Pin approvals to the requester in group chats
The _presentation_approval wrapper does this automatically by passing allowed_actors including the requesting user, owner_user_id, and admin_users. Only override when you have a specific reason.
Authorizers must be fast and pure
Authorizers run synchronously inside dispatch(). Any exception counts as a deny — the callback is rejected and an error is logged. Surface real errors in your application layer, not inside the authorizer.
Persist audit_log if you need long-term accountability
The in-memory log is bounded by history_limit (default 1000). For compliance or long-running bots, push entries to your logging sink (e.g. structured logging, database) before they are evicted.
Backward-compatible: omit authorize= to keep legacy behaviour
registry.register("namespace", handler) without authorize= allows any clicker — identical to the pre-PR behaviour. Useful for non-privileged callbacks such as read-only menus or informational polls.
Related
Tool approval backends (console, Slack, Telegram, webhook)
Durable ApprovalsPersist approvals to SQLite — including the --approval secure backend with actor authorisation and fail-closed start
CLI/terminal approval flow and persistence
Interactive Bot MessagesButtons, dropdowns, and approval prompts on Telegram/Slack/Discord
Bot Command Access ControlRestrict which users can run which bot commands
Text-keyword BackendsText-keyword backends and their allowed_approvers allowlist
Out-of-band BackendsHTTPApproval and WebhookApproval allowlists with single-use URL tokens