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.

python
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.

Diagram

Callback Authorization

True

False / raise

👤 User clicks button

🔒 InteractiveRegistry.dispatch

authorizer context

✅ Handler runs

❌ Rejected – no handler call

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

  1. Default — your bot is already protected

    If you run a Telegram, Slack, or Discord bot using the built-in _presentation_approval wrapper, 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_id and admin_users you configured) can click Approve or Deny. Other users in the same group chat are silently rejected.

  2. Restrict approvals to a specific user

    Pass allowed_actors when calling request_approval from 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.

  3. Register a custom interactive button with authorization

    Protect any interactive namespace (menus, polls, custom buttons) using an authorize callback:

    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 authorize returns False or raises, dispatch() returns False and 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).

Diagram
ToolHandlerAuthorizerInteractiveRegistryBotUserToolHandlerAuthorizerInteractiveRegistryBotUserUnauthorized — handler never runsalt[allowed][denied]clicks buttondispatch(context)authorizer(context)Truehandler(context)executeresultFalse / raisesreturn False

State of an approval ID:

Diagram

request_approval()

authorized click (approve / deny)

unauthorized click (left open)

timeout

pending

resolved

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?

Diagram

Single-user bot

Group chat, only requester decides

Group chat, requester + admins decide

Custom button in public chat

Out-of-band dashboard / webhook, shared network

What is your bot setup?

Default — no extra config

Pass allowed_actors=[requester_id]

Pass allowed_actors=[requester_id, *admin_users]
Default in _presentation_approval

registry.register with authorize= callback

HTTPApproval / WebhookApproval
with allowed_approvers=[…]


API Reference

SymbolSurfaceModule
InteractiveAuthorizertype alias Callable[[InteractiveContext], bool]praisonaiagents.bots.interactive
InteractiveRegistry.register(..., authorize=)registers handler + optional authorizerpraisonaiagents.bots.interactive
request_approval(..., allowed_actors=)binds approval to actor setpraisonai.bots._presentation_approval
handle_approval_command(..., actor=)enforces actor on resolvepraisonai.bots._presentation_approval
is_authorized(approval_id, actor)helper for custom adapterspraisonai.bots._presentation_approval
audit_log (property)copy of resolved-approval audit entriespraisonai.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).

FieldTypeDescription
approval_idstrUnique ID for this approval request
tool_namestrName of the tool awaiting approval
actor`str \None`User ID of the clicker (platform-prefixed, e.g. telegram:12345)
decisionstrallow, deny, or always
approvedboolWhether the tool was approved
authorizedboolWhether the actor was in the allowed set
timestampfloatUnix timestamp of the resolution

Three categories of entries are written:

Categoryapprovedauthorized
Authorized approvalTrueTrue
Authorized denialFalseTrue
Unauthorized attemptFalseFalse

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:

python
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:

python
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:

python
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:

python
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")
SituationEffect
No allowed_approvers configuredAny 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 buttonTelegram 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_idIgnored (chat-id binding added in PR #2582)
Approval times outApprovalDecision(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.

python
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:

python
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")
BackendEnforcement mechanismRejection behaviour
HTTPApprovalSingle-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 allowlist403 on missing/invalid token; 403 on unauthorised approver; approval stays pending
WebhookApprovalThe responder’s approver field (immediate POST response and polled status) is checked against the allowlistDecision 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:

Diagram
IntruderAuthorised operatorHTTPApprovalAgentIntruderAuthorised operatorHTTPApprovalAgentrequest_approval(execute_command, …)mint single-use token (allowlist configured)🔗 http://host:port/approve/<id>?token=<token>GET /approve/<id> (no token)403 Not authorizedPOST /approve/<id>/decide?token=<token>{approver: “alice”, decision: “approve”}compare_digest(token) + alice ∈ allowlistApprovalDecision(approved=True, approver=”alice”)

Env-var fallbacks mirror the chat backends:

bash
export HTTP_APPROVERS=alice,bob
export WEBHOOK_APPROVERS=alice,bob

Best 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