#5151·PraisonAI

Gateway approvals have no per-operator visibility or spawn-lineage audience — every APPROVALS-scoped operator sees (and is notified of) every agent's pending requests

Author: MervinPraisonCreated Sep 19, 2026Updated Sep 19, 2026
Labelsenhancementsecurityclaude

Summary

The gateway's human-in-the-loop approval surface exposes a single global pending queue. Every authenticated operator holding the APPROVALS scope can list — and be notified of — every pending tool/exec approval request across all agents, sessions and channels, including each request's tool name and raw arguments. There is no notion of an approval audience (which operator sessions/channels a given request is addressed to), and nothing consumes the multi-agent spawn lineage to route a request to the operator or channel that actually owns the originating agent.

In a single-operator deployment this is invisible. In the multi-agent, multi-channel, multi-operator deployments PraisonAI explicitly targets — BotOS orchestrating several Bots, AgentTeam/subagents, and per-channel approval_channel routing — it means an approval raised by one team's agent is visible and delivered to unrelated operators. That is both a confidentiality issue (the pending payload discloses another agent's tool and arguments, which may carry sensitive data) and a UX-noise issue that undermines a safe-by-default, world-class gateway.

This is distinct from already-shipped work. Resolver identity and per-request reviewer custodywho may resolve a known request — landed in #4288 (PR #4292); approval liveness landed in #4949. The residual gap is on the visibility and delivery side: which operators/sessions can see and are sent a request, and how that set is derived from the run's spawn lineage. Custody limits who can act on a request they can already see; it does not stop them seeing it or being notified.

Current behaviour

The pending endpoint returns the entire queue, unfiltered by operator, session or agent:

src/praisonai-bot/praisonai_bot/gateway/server.py (approval_pending)

python
async def approval_pending(request):
    """GET /api/approval/pending — list pending approval requests."""
    auth_err = _check_auth(request)
    if auth_err:
        return auth_err
    ...
    return JSONResponse({
        "pending": _approval_mgr.list_pending(),   # every pending request, all sessions/agents
        "allow_list": _approval_mgr.allowlist.list(),
    })

ExecApprovalManager.list_pending takes no visibility/audience/session filter:

src/praisonai-bot/praisonai_bot/gateway/exec_approval.py

python
def list_pending(self) -> List[Dict[str, Any]]:   # no `visible_to` / audience / session filter
    ...

The gateway approval backend already threads a session_id (and run_generation) into register(...), so each request is bound to an originating session — but that binding is used only for liveness/custody, never to scope visibility or delivery:

src/praisonai-bot/praisonai_bot/gateway/gateway_approval.py

python
request_id, future = await self.manager.register(
    tool_name=request.tool_name,
    arguments=request.arguments,
    agent_name=request.agent_name or "",
    risk_level=request.risk_level,
    authorized_reviewers=request.authorized_reviewers,
    session_id=request.session_id,          # bound, but not used for audience/visibility
    run_generation=run_generation,
)
# Notification is a single global webhook, or the poll-all endpoint above:
if self._notify_url:
    asyncio.create_task(self._notify(request_id, request))

The per-channel bot approval backends (_slack_approval.py, _telegram_approval.py, _discord_approval.py, ...) deliver to a statically configured approval_channel, not to the channel/operator that owns the agent which raised the request, and there is no propagation of a request up a subagent/hand-off tree.

Net effect: approval visibility and delivery are gateway-global; the session binding that already exists on every request is never used to narrow either.

Desired behaviour

  • A first-class approval audience: each pending request resolves to the set of operator sessions/channels entitled to see and receive it — minimally the originating session, and for multi-agent runs the sessions up the spawn/hand-off lineage that own or supervise the originating agent.
  • list_pending / GET /api/approval/pending filters by the requesting operator's audience membership, so an operator sees only the approvals addressed to them (they never receive another agent's tool name/arguments).
  • Delivery (webhook, per-channel backend, connected-client push) routes to the audience rather than a single global webhook or a broadcast poll set.
  • Safe degradation: audience resolution is routing metadata, never an approval safety prerequisite — if lineage/registry lookups fail, fall back to the agent-scoped source rather than failing the approval closed.
  • Fully backward-compatible: with no lineage and a single operator, the audience is "everyone", preserving today's behaviour.

Layer placement

  • Primary layer: core (praisonaiagents)
  • Why not core: it is core — the audience/visibility resolution is a pure protocol + policy over session/lineage identifiers with no heavy imports, and belongs beside the existing praisonaiagents.approval protocols that already define ApprovalRequest/ApprovalDecision.
  • Why not wrapper: the contract for "who may see and receive this request" must be shared by every backend (gateway HTTP, Slack/Telegram/Discord, webhook); defining it only in the wrapper would fragment it. The wrapper/praisonai-bot layer is the right home for the concrete filtering and delivery wiring (secondary touch), not the contract.
  • Why not tools: this is a gateway lifecycle/authorisation concern; the agent never calls it during a task.
  • Why not plugins: it is a first-class gateway trust invariant that must hold with no plugins installed, not an optional cross-cutting add-on.
  • Secondary touch: wrapper/praisonai-bot — audience-aware list_pending/endpoint filtering in gateway/server.py and gateway/exec_approval.py, plus lineage-aware delivery in the per-channel approval backends.
  • 3-way surface (CLI + YAML + Python): partial — primarily a Python/contract + gateway-runtime change; YAML already exposes per-channel approval_channel, which becomes an explicit audience input.

Proposed approach

  • Extension point: a protocol + default policy in the core approval package, consumed by the gateway manager and delivery backends.
  • Minimal API sketch:
python
# praisonaiagents/approval/audience.py  (core, pure — no heavy deps)
@dataclass(frozen=True)
class ApprovalAudience:
    session_keys: frozenset[str]            # sessions entitled to see/receive
    def includes(self, operator_session_key: str) -> bool:
        return not self.session_keys or operator_session_key in self.session_keys

@runtime_checkable
class ApprovalAudienceResolver(Protocol):
    def resolve(self, request: "ApprovalRequest", *, lineage: "SessionLineage") -> ApprovalAudience: ...
python
# ExecApprovalManager (praisonai-bot) — audience-aware listing (opt-in, back-compatible)
def list_pending(self, *, visible_to: str | None = None) -> list[dict]:
    # visible_to=None  -> today's global behaviour
    # visible_to set   -> only requests whose audience includes this operator
    ...

Resolution sketch

python
# Before (today): every APPROVALS-scoped operator sees & is notified of everything
GET /api/approval/pending -> _approval_mgr.list_pending()            # all sessions/agents

# After (proposed): scoped to the requesting operator's audience
operator = _resolve_operator_identity(request)
GET /api/approval/pending -> _approval_mgr.list_pending(visible_to=operator.session_key)

# Delivery follows the same audience up the spawn lineage rather than a single global webhook:
audience = audience_resolver.resolve(req, lineage=session_lineage_for(req.session_id))
for target in audience.session_keys:
    deliver_prompt(target, req)     # only the owning operator/channel is notified

Severity

High — on a multi-operator/multi-team gateway this is a confidentiality gap (an operator sees the tool names and raw arguments of agents they do not own) and a delivery-noise gap on the exact path that gates dangerous actions. It complements, and does not duplicate, the resolver-custody work in #4288: custody stops the wrong operator resolving a request; it does not stop them seeing or being notified of it.

Validation

  • Read src/praisonai-bot/praisonai_bot/gateway/server.py approval_pending (returns _approval_mgr.list_pending() unfiltered) and approval_resolve/approval_allowlist (gated by the APPROVALS scope only) — no per-operator/session visibility filtering on the pending list.
  • Read src/praisonai-bot/praisonai_bot/gateway/exec_approval.pylist_pending() and register(...) have no audience/visibility parameter; session_id/run_generation are stored but used only for liveness/custody.
  • Read src/praisonai-bot/praisonai_bot/gateway/gateway_approval.py — request is bound to session_id, but notification is a single global notify_url; nothing derives an audience.
  • Read the per-channel approval backends (_slack_approval.py, _telegram_approval.py, _discord_approval.py) — delivery targets a statically configured approval_channel, with no propagation up a subagent/hand-off lineage.
  • Cross-checked against closed approval issues (#4288 resolver identity/custody, #4949 liveness, #2626 durability, #3206 scopes): none scope the visibility of the pending list or derive a delivery audience from the spawn lineage — confirmed absent.