You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
security(gateway): outbound sends on the live channels are ungoverned — the control sits on a Gateway that is commented out of startup #14270
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
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.
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.
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.
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.
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.
$ 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:
require_approval=False is permanent for the notification family, so the approval mechanism can never gate that channel — only record it
the cross-tree stub loader in the slm health test needs a manual entry for every new backend top-level import, and a missed one fails as a bare ModuleNotFoundError that reads like a broken checkout
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.
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.
Activity
mrveiss commentedon Aug 15, 2026
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.mrveiss commentedon Aug 15, 2026
Blocked on #14261 landing.
services/gateway/egress_governor.pydoes not exist onDev_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
Gatewaywhile 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.mrveiss commentedon Aug 15, 2026
Confirmed independently while reviewing PR #14261, which does not close this gap.
That PR governs
Gateway.send_message, andinitialization/lifespan.py:403-424has Gateway construction commented out in production (Issue #881: TEMP DISABLED) — so the governed function has zero production callers. Meanwhile:api/telegram_bot.py:545andapi/whatsapp.py:81send directly, touching onlyingest_governorapi/integration_communication.py:186-188reaches Slack/Discord/Teams with no governor and no audit recordgit grep egress_governor origin/Dev_new_guireturns 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 #14067while the live channels stay ungoverned.security(gateway): govern outbound sends at the shared egress seam (#…
mrveiss commentedon Aug 16, 2026
The egress governor is now on
Dev_new_guias ofb30db78f0(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 viaservices.audit_loggerunder operationgateway.egress, with a broken sink degrading to a log line rather than dropping the message. There is a per-platformregister_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:
services/gateway/gateway_test.pyinclude 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_governoris 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.fix(slm): stub the egress governor in the cross-tree notification loa…
security(integrations): govern the sibling send paths review found un…
security(notifications): honour a denial instead of discarding the ve…
security(integrations): govern outbound sends on the live channel sea…
github-actions commentedon Aug 16, 2026
PR #14334 (merged to
Dev_new_gui) references this issue with a close keyword.If this issue is fully resolved, close it manually. If work remains, no action is needed.
mrveiss commentedon Aug 16, 2026
Closed — merged as 31dd133
Verified in base, not by merge status:
What review changed
The first version gated one of three Telegram senders. Review found
send_photoandsend_documentposting 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 gatedsend_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 —
_decidereturns allowed unconditionally whenrequire_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:
require_approval=Falseis permanent for the notification family, so the approval mechanism can never gate that channel — only record itModuleNotFoundErrorthat reads like a broken checkoutverdict.reasonis echoed in WhatsApp/Telegram denial payloads; harmless today, but feat(approvals): an approval can only be answered at the screen — no remote delivery, and 'nobody is watching' can only be expressed by lowering the guard profile #14068 will put raw approver exception text in itchat_idis unmasked in audit records while WhatsApp phone numbers are maskedmrveiss commentedon Aug 18, 2026
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.
mrveiss commentedon Aug 23, 2026
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-415still carriesIssue #881: TEMP DISABLED, so the governedGatewayclass 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.