Skip to content

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

Merged
mrveiss merged 14 commits into
Dev_new_guifrom
issue-14270-live-egress
Aug 16, 2026
Merged

mrveiss merged 14 commits into
Dev_new_guifrom
issue-14270-live-egress

Conversation

@mrveiss

@mrveiss mrveiss commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Thinking Path

#14067 put an egress control on Gateway.send_message — and that Gateway is dormant, its init
commented out at initialization/lifespan.py (Issue #881: TEMP DISABLED). This issue is the
half that carries real traffic: the modules that actually put bytes on the wire crossed no gate
and left no record.

Two scoping decisions were taken before writing code, because implementing #14270 literally
would have been wrong:

1. Operational alerts are audited but never blocked. notification_service carries
WORKFLOW_FAILED, SERVICE_FAILED and — decisively — APPROVAL_NEEDED. Gating that behind
the approval system deadlocks it: the notification telling a human an approval is needed would
itself await approval. Gating the other two silences outage alerts exactly when they matter.
These paths pass require_approval=False explicitly rather than relying on the env default, so
arming the policy cannot reach them.

2. Audit-only posture for the three agent-authored seams, matching what already merged for
the Gateway and the governor's own default. Fail-closed with no approver registered anywhere
would deny every send, so arming waits on #14068.

Two gaps in the issue's own scope, found by reading the producers rather than trusting the
table:

  • integration_communication.send_message has two send branches — a MessagingProtocol
    adapter and a Teams webhook fallback via execute_action. A guard inside the first branch
    would leave Teams ungoverned while the governor's name appears in the function, so the bypass
    reads as covered. The guard goes before the branch.
  • WhatsApp has four call sites on the messages endpoint, not the one the issue names:
    send_text_message, send_media_message, send_template_message, mark_message_read. The
    first three deliver something a person reads and are governed. mark_message_read is a read
    receipt — control traffic, the same reasoning as the Gateway's _AGENT_MESSAGE_TYPES — and is
    deliberately not governed, with a test pinning that so it stays a decision rather than drift.

What Changed

  • services/telegram_bot_service.py — guard in send_message before the HTTP POST; new
    require_approval: Optional[bool] = None parameter so callers can opt into the alert exemption.
  • integrations/whatsapp_integration.py — _egress_denied() helper; applied in the three
    person-facing senders. Recipient is masked in the audit record (_mask_phone) — a phone
    number is PII and the record outlives the send.
  • api/integration_communication.py — guard before the branch, covering both send paths;
    denial raises HTTPException(403), the right idiom for an API endpoint.
  • services/notification_service.py — _send_webhook (covers Slack) and the Telegram alert path
    both pass require_approval=False. The webhook audit record names the host only, not the
    full URL, which often carries a secret token.
  • services/gateway/live_egress_seams_test.py — new, 10 tests.
  • changelog/unreleased/14270-live-egress-seams.md — new fragment.

Unrelated fix in a file I was already editing: notification_service.py's module docstring
had an import injected into the middle of its usage example by some earlier tooling. Harmless
(it is inside a string) but wrong; removed. Pre-existing on base, not from this change.

Verification

./venv/bin/python -m pytest autobot-backend/services/gateway/ -q
# 52 passed  (42 existing + 10 new)

Every test drives the producer, not the governor — a test asserting the import exists would
pass while a new branch bypasses it. Each patches the governor to deny and asserts nothing
reaches the transport.

Mutation-tested:

Mutation Result
telegram guard never blocks 1 failed
whatsapp guard made inert 2 failed
integration_communication guard never blocks 1 failed
telegram alert exemption dropped 1 failed

That fourth row is the one worth reading. The first version of these tests did not catch it —
they proved TelegramBotService honours require_approval=False when passed, but nothing proved
notification_service passes it. Dropping the kwarg left all 9 tests green. That is invisible
until someone arms the policy and outage alerts stop, so a wiring test was added and the mutation
now fails.

Risks

  • Nothing blocks on merge. Default posture is audit-only; the only behaviour change is one
    audit record per outbound message. Alerts additionally cannot block even when armed.
  • The audit path is already wrapped in the governor — a Redis outage degrades to a log line,
    never a dropped message.
  • Arming AUTOBOT_GATEWAY_REQUIRE_OUTBOUND_APPROVAL with no approver registered will deny the
    three agent-authored channels. That is the documented fail-closed behaviour, and it is why the
    posture ships audit-only until 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 provides an approver.
  • mark_message_read remains ungoverned by design; if that judgement is wrong the test that
    pins it is the place to change it.
  • Rollback: revert the commit. No migration, no persisted-state change.

Pre-existing failure, not from this change:
integrations/integration_unknown_action_contract_test.py::test_at_least_one_subclass_discovered
fails identically on origin/Dev_new_gui — verified in a clean base worktree. It is the local
dependency gap (23/67 requirements absent), since CI's python-suite is green.

Model Used

Claude Opus 5 (claude-opus-5[1m])

Issue Link

Closes #14270

Related: #14067 (the dormant seam this complements), #14028 (inbound counterpart), #14068
(the approver that makes arming usable), #13421.

Changelog fragment

  • Added changelog/unreleased/14270-live-egress-seams.md

Checklist

  • Code follows AutoBot patterns from CLAUDE.md
  • Tests added or updated (or N/A with reason)
  • Documentation updated if behavior changed
  • Pre-commit hooks pass (git commit runs them automatically)
  • PR targets Dev_new_gui (not main)
  • No secrets or credentials in the diff

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No hardcoded values detected that have SSOT config equivalents!

@mrveiss

mrveiss commented Aug 16, 2026

Copy link
Copy Markdown
Owner Author

Red CI — not merging, and not yet root-caused

python-suite shard 1/12 is FAILURE (the aggregate python-suite check is downstream of it, so this is one root failure, not two).

I could not root-cause it: the run is still queued with other shards outstanding, and GitHub does not serve job logs until the whole run completes — gh run view --job … --log returns "run … is still in progress; logs will be available when it is complete".

Recording this rather than leaving it, because a red check with an unavailable log is exactly the shape that gets read as noise and waved through.

Next step for whoever picks this up: pull the shard log once the run finishes and check it against #14111 before assuming it is a real regression. #14111 tracks unrelated tests failing inside python-suite shards when the split shifts — root-caused there to xdist cross-worker git corruption, reproducible on CI and never locally. If this failure is that, it is not this PR's; if it is not, it is.

Either way it gets root-caused before merge — a tracking issue is not a substitute.

@mrveiss

mrveiss commented Aug 16, 2026

Copy link
Copy Markdown
Owner Author

Root-caused — it was mine, not the shard flakiness

The run finished and the log is retrievable now. Both reds are one root failure (python-suite is downstream of the shard):

autobot-slm-backend/slm/agent/health_collector_state_change_test.py:192
  → autobot-backend/services/notification_service.py:49
E   ModuleNotFoundError: No module named 'services.gateway'

This PR caused it. It added from services.gateway.egress_governor import egress_governor as a top-level import in notification_service.py. An slm-backend test loads that backend module by path and stubs its dependencies in sys.modules — and my new import was not in the stub list. Under the slm rootdir, services resolves to the slm package, so services.gateway is genuinely absent there.

I had flagged #14111 as the likely explanation. It was not — checking rather than assuming was the difference.

Fix

Added the two missing stubs alongside the existing ones, with a comment explaining why every new backend top-level import has to be mirrored there.

Verified against the reproduction, not the predicate. The failure never appears from the repo root, which is why it looked CI-only. Running with the slm rootdir reproduces it exactly:

$ cd autobot-slm-backend && pytest slm/agent/health_collector_state_change_test.py
E   ModuleNotFoundError: No module named 'services.gateway'; 'services' is not a package
    2 failed  ← same two tests as CI

$ # with the stubs restored, same invocation
    14 passed

Pushed as 058348d31, rebased onto the current branch tip.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant