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
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 custody — who 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)
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
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
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/pendingfilters 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.approvalprotocols that already defineApprovalRequest/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-botlayer 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-awarelist_pending/endpoint filtering ingateway/server.pyandgateway/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:
# 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: ...# 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
# 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 notifiedSeverity
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.pyapproval_pending(returns_approval_mgr.list_pending()unfiltered) andapproval_resolve/approval_allowlist(gated by theAPPROVALSscope only) — no per-operator/session visibility filtering on the pending list. - Read
src/praisonai-bot/praisonai_bot/gateway/exec_approval.py—list_pending()andregister(...)have no audience/visibility parameter;session_id/run_generationare stored but used only for liveness/custody. - Read
src/praisonai-bot/praisonai_bot/gateway/gateway_approval.py— request is bound tosession_id, but notification is a single globalnotify_url; nothing derives an audience. - Read the per-channel approval backends (
_slack_approval.py,_telegram_approval.py,_discord_approval.py) — delivery targets a statically configuredapproval_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.
Source: MervinPraison/PraisonAI