Skip to content

security(gateway): outbound sends on the live channels are ungoverned — the control sits on a Gateway that is commented out of startup #14270

Closed

Description

@mrveiss

Problem

An agent can send a message to a real Telegram, WhatsApp, Slack, Discord or Teams recipient with
no approval gate and no audit record. The send paths that carry irreversible messages to people
do not pass through any egress control.

Verified against origin/Dev_new_gui:

path control evidence
api/telegram_bot.py none grep -c egress_governor → 0; sends via TelegramBotService.send_message; its own comment calls this "the actual live send seam"
api/whatsapp.py none 0; sends via integration.send_text_message
api/integration_communication.py none POST /integrations/communication/{provider}/messages → MessagingProtocol.send_message for Slack/Discord/Teams
services/notification_service.py none Slack webhook and Telegram sends for notification delivery
services/gateway/gateway.py governed (#14261) but see below

All four ungoverned paths record to ingest_governor — the inbound recursion guard — which is
easy to mistake for governance when reading quickly. It answers a different question.

Why the governed seam does not cover them

Gateway.send_message is where the control lives. initialization/lifespan.py:406 has Gateway
initialization commented out (Issue #881: TEMP DISABLED), so app.state.gateway is never
set, and the commented-out block registered only a WebSocketAdapter — never an external channel.

So the class that is governed is dormant, and the modules that actually send are not governed.
Two parallel implementations of "the gateway", with the control on the one that is switched off.
This is the egress half of #14028's "two ungoverned adapter stacks".

Fix

  • egress_governor.evaluate(...) on the live seams: api/telegram_bot.py, api/whatsapp.py,
    api/integration_communication.py, services/notification_service.py.
  • Fail closed there as it already does in egress_governor — no approver means deny, not send.
  • A guard test that fails when a new outbound send site appears without the control. Grep the
    producer — the adapter call that puts bytes on the wire — not the governor's name, or a
    new bypass reads as covered.
  • Decide what happens to the dormant Gateway. Either it comes back and the live paths route
    through it, or the control moves to where sends happen. Two implementations with the guard
    on the unused one is how this arose.

Acceptance criteria

  • A send on each live channel is refused when the governor denies, proven per channel rather
    than once through Gateway.
  • A new send site cannot be added without either the control or an explicit, reviewed waiver.
  • ingest_governor and egress_governor are distinguishable at a glance at every call site,
    so recording to the inbound guard is never mistaken for governing the outbound send.

Context

Found reviewing PR #14261, which builds the control correctly and wires it to the dormant path.
That PR is a real improvement and should not be held for this; this is the part that makes the
guarantee true.

Activity

  1. mrveiss commented on Aug 15, 2026

    @mrveiss
    OwnerAuthor

    Surfaced reviewing PR #14261, which builds the egress control correctly and wires it to Gateway.send_message. That PR is a real improvement and should not be held for this — the control fails closed and its tests drive the governed path properly. This issue is about where the control sits relative to where sends happen.

  2. mrveiss commented on Aug 15, 2026

    @mrveiss
    OwnerAuthor

    Blocked on #14261 landing. services/gateway/egress_governor.py does not exist on Dev_new_gui — it is only in that unmerged PR.

    So this cannot start yet without either depending on an unmerged branch that may still change, or building a second governor beside it. The second option is worse than waiting: two implementations of one control is how the situation this issue describes arose in the first place (the guard on Gateway while the live paths send directly).

    Recording it here so nobody starts it prematurely. Once #14261 merges, the work is wiring egress_governor.evaluate(...) into the four live send sites and adding the producer-side guard test.

  3. mrveiss commented on Aug 15, 2026

    @mrveiss
    OwnerAuthor

    Confirmed independently while reviewing PR #14261, which does not close this gap.

    That PR governs Gateway.send_message, and initialization/lifespan.py:403-424 has Gateway construction commented out in production (Issue #881: TEMP DISABLED) — so the governed function has zero production callers. Meanwhile:

    • api/telegram_bot.py:545 and api/whatsapp.py:81 send directly, touching only ingest_governor
    • api/integration_communication.py:186-188 reaches Slack/Discord/Teams with no governor and no audit record

    git grep egress_governor origin/Dev_new_gui returns 0 hits outside that PR's new files.

    So this issue remains fully live regardless of whether #14261 merges. I have flagged on that PR that it should not carry Closes #14067 while the live channels stay ungoverned.

  4. mrveiss commented on Aug 16, 2026

    @mrveiss
    OwnerAuthor

    The egress governor is now on Dev_new_gui as of b30db78f0 (PR #14261, closing #14067), so the control this issue needs to apply already exists and does not have to be designed here.

    services/gateway/egress_governor.py — egress_governor.evaluate(platform=, channel_id=, message_id=, require_approval=None) → EgressVerdict{allowed, reason, rule}. It already does what this issue's second checkbox asks for: fails closed when armed — no registered approver, an approver that denies, and an approver that raises all deny. Every governed call is recorded via services.audit_logger under operation gateway.egress, with a broken sink degrading to a log line rather than dropping the message. There is a per-platform register_approver(platform, approver) registry for the delivery path.

    Default is audit-only (AUTOBOT_GATEWAY_REQUIRE_OUTBOUND_APPROVAL, registered), so applying it to the four live seams records without blocking until you decide to arm it per channel.

    This issue's framing is correct and mine was not. #14067 asserted that outbound messages were leaving through the Gateway ungated; they were not, because the Gateway is switched off — which is exactly the point this issue makes. I have corrected the record on #14067 rather than leaving the overstatement standing.

    Two things worth carrying into the implementation here:

    • The wiring tests in services/gateway/gateway_test.py include one that registers a brand-new adapter subclass with no knowledge of egress governance and asserts it is still gated. The equivalent guard for the live seams is this issue's third checkbox — and its phrasing is right: grep the producer that puts bytes on the wire, not the governor's name, or a new bypass reads as covered.
    • egress_governor is scoped to agent-authored message types at the Gateway seam via _AGENT_MESSAGE_TYPES. The live seams have no equivalent notion, so each needs its own answer to "is this a message to a person, or control traffic?" before the gate goes on — otherwise notification-service health pings start requiring approval.
  5. github-actions commented on Aug 16, 2026

    @github-actions
    Contributor

    PR #14334 (merged to Dev_new_gui) references this issue with a close keyword.

    security(integrations): govern outbound sends on the live channel seams (#14270)

    If this issue is fully resolved, close it manually. If work remains, no action is needed.

  6. mrveiss commented on Aug 16, 2026

    @mrveiss
    OwnerAuthor

    Closed — merged as 31dd133

    Verified in base, not by merge status:

    $ git show origin/Dev_new_gui:autobot-backend/services/telegram_bot_service.py | grep -c _egress_denied
    4      # the helper + all three senders
    
    $ git show origin/Dev_new_gui:autobot-backend/api/integration_communication.py | grep -c egress_governor.evaluate
    2      # send_message AND the sibling webhook endpoint
    
    $ git show origin/Dev_new_gui:autobot-backend/services/notification_service.py | grep -c 'if not verdict.allowed'
    2      # both notification seams honour a denial
    
    $ git show origin/Dev_new_gui:autobot-backend/services/gateway/live_egress_seams_test.py | grep -c 'def test_'
    15
    

    What review changed

    The first version gated one of three Telegram senders. Review found send_photo and send_document posting to the Bot API with no gate — both reachable from the agent-response dispatcher this control exists to protect, so an agent answering with an image bypassed it entirely. Also ungoverned: send_webhook_message, a sibling route rather than a branch of the gated send_message, and the SMTP channel.

    The shape that hid all three is worth recording: the governor's name appears in each of those files, so a coverage check finds it and stops. Grepping the module said "governed"; grepping the behaviour — what actually puts bytes on the wire — said otherwise.

    The Telegram gate is now one helper, not a check copied per method. A copied security check is one the next sender omits, which is exactly how this happened.

    A separate security review then found both notification seams awaiting the governor and discarding the verdict. Not a reachable bypass — _decide returns allowed unconditionally when require_approval=False — but it made the seams depend on the governor's present shape, silently. They now honour a denial while keeping the alert exemption, so arming the approval policy still cannot deadlock an APPROVAL_NEEDED alert.

    Mutation evidence, each gate pinned independently: photo+document 2, Teams webhook 1, email 1, discarded verdict 2.

    Deliberately left for follow-up

    Filing separately rather than growing this PR further:

  7. mrveiss commented on Aug 18, 2026

    @mrveiss
    OwnerAuthor

    Follow-ups filed, all cross-linked: #14537 (the notification family can never be approval-gated, and the changelog does not say so), #14538 (cross-tree stub loader), #14539 (denial payloads will echo raw approver exception text once #14068 lands), #14540 (Telegram chat_id unmasked while WhatsApp is masked).

    The changelog correction rides on #14537 rather than a stray doc PR — it is the same subject, since the wording is only wrong because of the hardcoded exemption it describes.

  8. mrveiss commented on Aug 23, 2026

    @mrveiss
    OwnerAuthor

    Filed #14904 for the checklist item this issue closed without: "Decide what happens to the dormant Gateway."

    The live seams are governed now, which is what closed this. But initialization/lifespan.py:408-415 still carries Issue #881: TEMP DISABLED, so the governed Gateway class remains the seam nothing reaches while the modules that carry traffic were governed separately. That is two parallel implementations with the guard on the unused one — exactly what this issue's own "Fix" section warned about — and it needed its own issue rather than closing with the rest.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendbugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions