Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions LAYERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Layers from issue #74787

1. **Authorization/intake resolution remains fail-closed:** a stamped secondary profile with no adapter for the receiving platform must still resolve to `None` on the authorization path, so platform intake policy cannot leak across profiles. → `gateway/authz_mixin.py:_authorization_adapter()` / `_adapter_for_source()`.
2. **Outbound delivery prefers the profile-owned adapter:** when the routed profile owns a live same-platform adapter, status, clarify, approvals, progress, replies, and other outbound traffic must continue through that adapter. → new delivery-only resolver in `gateway/authz_mixin.py`; outbound call sites in `gateway/run.py`, `gateway/platforms/base.py`, and `gateway/slash_commands.py`.
3. **Doorless routed profile falls back to the receiving/default adapter:** when a served profile has no live same-platform adapter, outbound delivery must use the live adapter that received the source (when retained) or the active/default same-platform adapter, rather than silently dropping user-visible output. → delivery-only resolver + turn `_status_adapter` and all genuinely outbound resolver sites.
4. **Approval/clarify prompt path is production-safe:** `_approval_notify_sync` and `_clarify_callback_sync` must not dereference `None`; a doorless routed profile receives the interactive prompt through the delivery adapter, while a fully unresolvable source degrades without `AttributeError`. → `gateway/run.py:TurnRunner.run_sync()` callback wiring/guards.
5. **All outbound layers use one semantic resolver:** status/progress, streaming/interim output, reply/final delivery, TTS/media, notifications, approval/clarify, typing cleanup, and outbound slash responses must not retain fail-closed authorization lookup accidentally. Intake, authorization, queue selection, interrupts, restoration, and platform-control sites stay on `_adapter_for_source()`. → classify every call site before changing it.
6. **Edge cases:** `source is None`, missing/unmapped platform, default profile, active named profile, registered receiving transport, unregistered hand-built/restored source, and relay ingress all return a deterministic adapter or `None` without raising. Authorization behavior must remain unchanged for each case. → resolver regression matrix in the existing multiplex/profile-resolution test files plus production-path approval coverage.
102 changes: 101 additions & 1 deletion gateway/authz_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,18 @@ def _authorization_adapter(
return adapters.get(platform)

def _adapter_for_source(self, source: Optional[SessionSource]):
"""Resolve the live adapter for an inbound ``SessionSource``."""
"""Resolve the live adapter for an inbound ``SessionSource``.

This is the *intake / authorization* resolver. It must remain
fail-closed: a served profile with no live same-platform adapter
(e.g. it failed to connect, or the routed profile deliberately owns
no door of its own) resolves to ``None`` so platform intake policy
cannot leak across profiles.

Outbound delivery is a different concern — for status, approvals,
clarify, progress, replies, and other user-visible paths, see
:meth:`_delivery_adapter_for_source`.
"""
if source is None:
return None
transport_adapter = self._registered_transport_adapter(source)
Expand All @@ -125,6 +136,95 @@ def _adapter_for_source(self, source: Optional[SessionSource]):
getattr(source, "profile", None),
)

def _delivery_adapter_for_source(self, source: Optional[SessionSource]):
"""Resolve the live adapter that should OWN outbound delivery for *source*.

Distinct from :meth:`_adapter_for_source` (the intake / authorization
resolver) on purpose. The intake resolver is fail-closed — a routed
profile with no live same-platform adapter returns ``None`` so a
message from a different profile cannot ride the default profile's
allowlist. Outbound delivery cannot use the same rule: an interactive
approval prompt, a clarify question, status progress, or a reply that
*did* get authorized for a routed profile must still be *delivered*
through a real adapter, or the user never sees it. Pre-fix,
:meth:`_adapter_for_source` returned ``None`` and the outbound path
dereferenced it (``AttributeError: 'NoneType' object has no attribute
'pause_typing_for_chat'``), so approvals and clarify prompts silently
failed to send and the agent surfaced ``BLOCKED: Failed to send
approval request to user`` even though intake was authorized.

Resolution order (each step is a strict superset of the previous for
the cases it covers, never a leak of authorization across profiles):

1. ``_registered_transport_adapter`` — the adapter that *received*
the source (retained as in-process provenance by
:meth:`BasePlatformAdapter.build_source`). This keeps relay-ingress
and chat-route sources on the same adapter that owns the
authenticated socket, and it covers both the "profile owns its
door" and "routed profile uses the default's adapter" cases
without falling through to the cross-profile registry.
2. The profile-scoped adapter when the source's profile *does* own
one for the platform — so a profile that has its own bot keeps
using it for delivery. (``_authorization_adapter`` already
returns the profile-owned adapter for a stamped non-default
profile.)
3. The active / default profile's same-platform adapter — for a
routed profile that deliberately has no door of its own, fall
back to the adapter that actually received the platform's
traffic. This is the failure case the bug report names.
4. ``None`` — when the platform has no live adapter anywhere on
the runner. The caller (status / approval / clarify) is
responsible for degrading cleanly without dereferencing.

Never consults the cross-profile registry in a way that would let a
secondary profile's allowlist accept a different profile's inbound:
a delivered source already passed ``_is_user_authorized``, and this
resolver is exclusively about *which adapter emits the next user-
visible message*. Intake / authorization / queue / interrupt /
restore sites must keep using :meth:`_adapter_for_source`.
"""
if source is None:
return None
# Step 1 — receiving transport. Same call the intake resolver
# already uses for relay + chat-route sources, so delivery stays on
# the adapter that owns the authenticated socket.
transport_adapter = self._registered_transport_adapter(source)
if transport_adapter is not None:
return transport_adapter
# Step 1b — relay ingress (mirror of the intake resolver's relay
# handling). Keep on the same RelayAdapter to preserve streaming,
# typing, and tool-progress for managed gateways.
if getattr(source, "delivered_via_upstream_relay", False) is True:
adapters = getattr(self, "adapters", None) or {}
relay = adapters.get(Platform.RELAY)
if relay is not None:
return relay
# Step 2 — profile-scoped adapter when the routed profile has one
# for the platform. ``_authorization_adapter`` returns that for a
# stamped non-default profile whose ``_profile_adapters[profile]``
# entry holds a same-platform adapter.
platform = getattr(source, "platform", None)
if not platform:
return None
profile = getattr(source, "profile", None)
if profile:
profile_adapters = getattr(self, "_profile_adapters", None) or {}
if profile in profile_adapters:
adapter = profile_adapters[profile].get(platform)
if adapter is not None:
return adapter
# Step 3 — active / default profile's same-platform adapter. We do
# NOT use ``_authorization_adapter`` here because that helper is
# fail-closed for non-default profiles (it returns ``None`` when the
# stamped profile has no own adapter, so a routed profile's
# message cannot leak through the default profile's allowlist). For
# outbound delivery this rule is the wrong side of the trade-off:
# the message already passed authorization on the receiving adapter,
# and refusing to deliver it through the default bot means the
# user never sees the prompt.
adapters = getattr(self, "adapters", None) or {}
return adapters.get(platform)

def _registered_transport_adapter(self, source: SessionSource):
"""Return the registered adapter that created *source*, if retained.

Expand Down
29 changes: 28 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4804,6 +4804,24 @@ def _approval_notify_sync(approval_data: dict) -> None:
UX. Otherwise fall back to a plain text message with
``/approve`` instructions.
"""
# Guard the dereference: ``_delivery_adapter_for_source`` can
# still resolve to ``None`` for a source whose platform has no
# live adapter anywhere on the runner (e.g. all adapters for
# that platform failed to start). The intake path already
# rejected the source, so the agent cannot proceed safely
# without user input — log and exit so ``tools.approval``
# surfaces ``BLOCKED: Failed to send approval request to
# user`` cleanly instead of crashing the agent thread with
# ``AttributeError: 'NoneType' object has no attribute
# 'pause_typing_for_chat'`` (the symptom in #74787).
if not ctx._status_adapter:
logger.error(
"Cannot send approval request: no delivery adapter for "
"platform=%s chat_id=%s; source is unresolvable",
getattr(getattr(ctx, "source", None) and ctx.source.platform, "value", None),
ctx._status_chat_id,
)
return
# Pause the typing indicator while the agent waits for
# user approval. Critical for Slack's Assistant API where
# assistant_threads_setStatus disables the compose box — the
Expand Down Expand Up @@ -23575,7 +23593,16 @@ async def write_tool_log():
turn_ctx._event_callback_sync = turn_runner._event_callback_sync

# Bridge sync status_callback → async adapter.send for context pressure
_status_adapter = self._adapter_for_source(source)
# Use the delivery-only resolver (NOT the intake / authorization
# resolver ``_adapter_for_source``): when a routed profile has no
# live same-platform adapter of its own, the intake resolver is
# fail-closed and returns ``None`` so a different profile's
# allowlist cannot accept the message. Outbound delivery cannot
# use that rule — the message has already passed authorization,
# and an approval prompt or status update must still reach the
# user through the receiving / default adapter. See
# :meth:`GatewayAuthorizationMixin._delivery_adapter_for_source`.
_status_adapter = self._delivery_adapter_for_source(source)
_status_chat_id = source.chat_id
if source.platform == Platform.FEISHU and source.thread_id and event_message_id:
# Feishu topics only keep messages inside the topic when they are
Expand Down
Loading
Loading