56 Commits

Author SHA1 Message Date
Teknium a6e934e0fd feat(gateway): 'decline' unauthorized-DM behavior — one-time polite decline instead of pairing code
Port from qwibitai/nanoclaw#3260: adds a third unauthorized_dm_behavior
option, 'decline'. Instead of replying with a pairing code (pair) or
staying silent (ignore), the gateway sends one short, polite decline to
the unknown sender, then stays silent toward that sender for 24 hours.

- gateway/config.py: accept 'decline' in the normalizer; new
  unauthorized_dm_decline_message for custom decline text (round-trips
  through to_dict/from_dict).
- gateway/pairing.py: persisted decline stamps (_declined.json) on
  PairingStore with has_recent_decline/record_decline; stamps are
  pruned on write and recorded BEFORE delivery so a send failure can't
  become a decline storm (nanoclaw's stamp-first pattern).
- gateway/run.py: decline branch in the unauthorized-sender path;
  groups still always silently ignore.
- docs: security.md + configuration.md updated.

Adapted from TypeScript (NanoClaw's pending_sender_approvals 'decline:'
stamp rows) to Hermes' existing PairingStore JSON persistence; the
owner-FYI half of nanoclaw's flow is intentionally not ported — Hermes
logs the unauthorized attempt, and pairing remains the owner-visible
grant path.

Rebase onto the decomposed gateway (salvage, #88028):
- The unauthorized-sender path moved from gateway/run.py to
  gateway/run_inbound.py::_hm_admit_event; the decline branch is a sibling
  helper _hm_send_unauthorized_decline next to _hm_offer_pairing_code.
- gateway/config.py now validates the enum via _normalize_choice; the
  accepted set is the module constant UNAUTHORIZED_DM_BEHAVIORS (used by
  both from_dict and get_unauthorized_dm_behavior so a per-platform
  `extra.unauthorized_dm_behavior: decline` is honoured too). The default
  decline text lives in config as DEFAULT_UNAUTHORIZED_DM_DECLINE_MESSAGE.
- Tests trimmed from 5 to 2 invariant tests (send-once-then-silent through
  the real inbound path; config round-trip + real PairingStore stamp
  lifecycle with a patched clock instead of rewriting the JSON file).
2026-09-15 03:44:13 -07:00
teknium1 b4d1b98629 fix(gateway): the central allow_bots grant honours a secondary profile's YAML policy
GatewayAuthorizationMixin._chat_scoped_grant read only the scoped
{PLATFORM}_ALLOW_BOTS env var, so a secondary whose config.yaml said
`allow_bots: all` was admitted by its own adapter and then denied centrally
(Discord, Slack Workflow posts with user=None, Feishu, Telegram). The gate now
resolves the routed adapter's effective policy with the same reader as intake:
scoped env → adapter YAML → none. Mention requirement and loop guard are
unchanged.

Refs #108440 (finding 7)
2026-09-13 15:39:11 -07:00
teknium1 122ad719b5 fix(telegram): runner-side allowlist gate decodes JSON list strings too
The adapter fix decoded `'["-100","-200"]'` before comma-splitting, but
the runner's central gate in gateway/authz_mixin.py::_coerce_allow_set
reads the same YAML-bridged env chain (TELEGRAM_GROUP_ALLOWED_CHATS,
TELEGRAM_ALLOWED_USERS via _auth_env) and still produced
{'["1"', '"2"]'}, so a group message admitted by the adapter could
still be rejected upstream.

Move the decoder to gateway/platforms/_shared.py, which both the adapter
and authz_mixin already import from (no plugin -> gateway cycle), and
route _coerce_allow_set through it. One invariant test on the runner
side, red before this change.
2026-09-13 14:41:01 -07:00
teknium1 de114b3af1 refactor(platforms): one scoped-secret reader and spec-driven enablement/YAML-bridge boilerplate across all adapters
Scoped secrets — `gateway.platforms._shared.get_scoped_secret` is the single implementation of
the "scope authoritative, unscoped default-profile falls back to os.environ" read:

- plugins/platforms/buzz/adapter.py::_get_scoped_secret (113 LOC, ~100 of which were one
  docstring paragraph pasted 16x) -> 3-line forwarder over the canonical with
  `external_fallback=True`. Its one genuine extra rung (one-shot profile-scope build so a
  Bitwarden-managed key is visible to the startup gate, #95216) moves into `_shared` as that
  keyword plus `_unscoped_profile_secrets`.
- weixin::_wx_secret, matrix::_startup_env_secret, the inline try/except copies in slack
  (SLACK_APP_TOKEN) and telegram (TELEGRAM_WEBHOOK_SECRET/_URL) -> canonical.
- The "extra-first, then scoped env" reader written 11x under 6 names (weixin._extra_or_env,
  bluebubbles/ntfy/photon/wecom `_setting`, dingtalk `_extra_get`, mattermost `_extra_or_env`,
  slack `_extra_or_env_flag/_channel_set`, feishu closures) -> `_shared.extra_or_secret`.
- `authz_mixin._platform_gate_env` -> `_shared.platform_gate_env`; discord/telegram drop their
  `_scoped_gate_env` twins; run.py / run_config_loaders.py / slack import it directly.

Boilerplate — three table-driven helpers in `_shared` replace the pasted docs template:

- `seed_extra_from_env(spec, home_env=)` replaces 8 `_env_enablement` bodies (buzz, google_chat,
  irc, line, ntfy, photon, simplex, teams; raft is a one-liner and untouched).
- `apply_yaml_bridge(cfg, spec)` replaces 7 `_apply_yaml_config` bodies (buzz, dingtalk, feishu,
  matrix, mattermost, slack, whatsapp); discord/telegram keep bespoke bridges (alias keys,
  nested `platforms.*.extra`, generic-key exclusions). buzz and mattermost previously bypassed
  `yaml_env_setter` with hand-rolled `os.environ` writes.
- `env_is_connected(*vars)` replaces 5 identical `_is_connected` (discord, homeassistant,
  mattermost, slack, sms).
- 8 identity `_build_adapter` wrappers deleted; `adapter_factory=<Class>`.

Behavior change:
- buzz `_apply_yaml_config` returned None, so under multiplex a secondary Buzz profile got
  neither env (correctly skipped) nor `extra` for relay_url/channels/allow_all_users/...; it now
  seeds `extra` like every other hook. It also wrote reply_in_thread/reply_to_mode to the process
  env even inside a secondary profile's scope (first-writer-wins leak, #80099 class); it no longer
  does. BUZZ_POLL_INTERVAL is bridged through the same table.
- `home_channel.name` default when `<X>_HOME_CHANNEL_NAME` is unset is now the literal "Home" for
  all plugins (irc/ntfy/buzz used the chat id; simplex/teams/photon/google_chat already used
  "Home", as do the built-in platforms in gateway/config_env.py).
- weixin's non-secret tunables (send_chunk_*, rate_limit_circuit_*) now read through the scoped
  reader instead of raw os.getenv — a secondary profile no longer inherits the default's values.
- `extra_or_secret` treats a blank string in extra as unset (falls to env) and an explicit False
  as a real value, the strictest of the merged copies.
- slack `reaction_trigger_target` bridges via str(); `reaction_triggers` comma-joins any list-ish
  value (was list/tuple/set only) — same env text for every real YAML shape.

Docs: website/docs/developer-guide/adding-platform-adapters.md (the template the copies were
pasted from) and gateway/platforms/ADDING_A_PLATFORM.md now show the helpers and the scoped
reader; gateway/AGENTS.md points at the one implementation.

Tests: tests/gateway/test_shared_platform_boilerplate.py — every plugin `_env_enablement`
reads only through the scoped getter (parametrized over the 8 plugins, spy on the seam, raw
`os.getenv`/`get_env_value` asserted untouched); buzz bridge seeds `extra` for a secondary
profile and still bridges env for the default; one home-name rule; extra_or_secret contract;
external_fallback rung. Existing tests repointed: tests/agent/test_secret_scope_tier1_migration.py,
tests/plugins/platforms/buzz/test_buzz_unscoped_requirement_gate.py.
2026-09-13 05:32:38 -07:00
Teknium 77180acc2c fix(gateway): mid-turn authorization reads the admitting bot's allowlist; shared-bot satellites keep a transport
Inside a routed satellite's turn the ambient scope is the satellite's, whose
.env has no token or allowlist. Five sites still called `_is_user_authorized()`
directly there — `/topic`, the sibling-thread `/stop` grant, plugin message
injection, Discord voice transcripts and startup auto-resume — so the shared
bot's owner was refused ("not authorized to use /topic") and a satellite that
DID copy an allowlist widened who may drive those commands. They now go through
`_is_user_authorized_for_source`, and `_under_authorization_profile` derives the
transport home from the delivering adapter's owner when ingress did not stamp
one (restored/cached sources).

`_adapters_for_profile` (the resolver behind `_authorization_adapter`,
`_adapter_for_source` and now `_resolve_injection_adapter`) returns the primary
map for a shared-bot satellite: a served profile with the `{}` startup
placeholder, no reconnect pending, targeted by a default-bot route. Kanban and
cron already applied that rule; the gateway's own resolvers returned None, so
heartbeats, process completions, goal notices and delegation results for such
profiles were undeliverable after a restart. A secondary that owns a credential
(connected on any platform, or queued for reconnect) still fails closed.
2026-09-11 15:28:00 -07:00
Teknium 45a6101f36 fix(gateway): secondary-profile send_message, notices and /loop wakeups go out via their own bot
Under gateway.multiplex_profiles a turn running for a secondary profile P resolved
its live adapter by bare platform from runner.adapters — the DEFAULT profile's map —
so P's send_message tool calls (send/react/media on slack, matrix, wecom, buzz, ntfy,
every plugin platform), its "Gateway shutting down/restarted" and /update notices,
its /loop wakeups, and its Discord bot's unauthorized-slash operator alert all left
through the default bot (Telegram DMs landed in the user's chat with the other bot).

Every such door now resolves through the profile-aware, fail-closed resolver already
used by the inbound reply path (authz_mixin: _adapters_for_profile /
_authorization_adapter / _adapter_for_source): P's own adapter, or None → a clear
error, never the default bot.

- tools/send_message_senders.py::_live_adapter — resolve via
  runner._authorization_adapter(platform, get_active_profile_name()); shared by
  _send_via_adapter, _handle_react/unreact, media sends, matrix E2EE fast path and
  the WeCom standalone sender.
- gateway/authz_mixin.py — extract _adapters_for_profile (the whole map, for relay-
  aware resolve_delivery_transport callers); _authorization_adapter reuses it.
- gateway/run_shutdown.py — shutdown/restart notice for a running session uses the
  session's source transport / agent:<profile>: key lane, never self.adapters.
- gateway/slash_commands.py + run_notifications.py — /restart and /update markers
  persist `profile`; the restart notice, update result and update prompt resolve the
  requester's own adapter (legacy markers fall back to the session_key lane).
  /goal, /heartbeat, /approve, /deny confirmations use the source's own transport.
- gateway/slash_commands_goals.py + run_goals.py — /loop persists `profile` in its
  route; the wakeup watcher scans every served profile's store under its own scope
  (same shape as _handoff_watcher) and fires through that profile's adapter map.
- plugins/platforms/discord/adapter.py::_notify_unauthorized_slash — alert stays in
  the owning profile (its adapters and its home channels).
- plugins/platforms/wecom/adapter.py::_standalone_send — via _live_adapter.
- docs: website/docs/user-guide/multi-profile-gateways.md (outbound identity).

Tests (red on base): tests/tools/test_send_message_multiplex_profile_adapter.py,
tests/gateway/test_multiplex_notice_egress_profile_adapter.py.

Co-authored-by: pierrenode <298902573+pierrenode@users.noreply.github.com>
2026-09-10 18:17:22 -07:00
Erosika 55b3ea0b11 fix(gateway): count each bot message once in the loop guard and consume the author variable
The Telegram adapter asks the authorization check before dispatch, the ingress gate asks it
again, and the busy path asks a third time. Each call counted one loop-guard event, so a
Telegram bot tripped the budget after a third of the configured messages. The verdict now only
refuses a chat that is cooling down. The ingress gate counts an admitted bot message once.

`parse_turn_author` treats only booleans, integers and the strings true/1/yes as a bot flag,
and returns None for an author with neither id nor name. Names keep format characters and
non-breaking spaces so emoji sequences survive. The quiet one-shot pops HERMES_TURN_AUTHOR
before the turn so tool subprocesses do not inherit it. `max_events` must be a whole positive
number. Issue numbers move out of code comments.
2026-09-10 10:27:07 -07:00
Erosika 5bf69e963c fix(gateway): loop guard judges the final authz verdict and reads config.yaml
#91483 added the guard inside the ALLOW_BOTS block. on today's authz layout
an admitted bot returns from an earlier rule before that block runs, so the
guard never tripped (its own ping-pong test passes turn 21 on this tree).

_is_user_authorized now computes the allowlist verdict first and runs every
admitted bot-authored message through the guard, so bots admitted by a chat
allowlist, allow-all, pairing or delegation are metered too. the budget stays
per conversation. settings move from HERMES_BOT_LOOP_* environment variables
to config.yaml gateway.bot_loop_guard (defaults added to config_defaults),
re-read on every call. an unauthorized bot dm no longer gets a pairing code,
so a cooldown produces no outbound traffic. tests rebuilt against real
SessionSource objects.
2026-09-10 10:27:07 -07:00
69k4xmdfm2-blip 3a37785f7f fix(gateway): add bot-to-bot loop guard for Telegram
Telegram Bot API 10.0 (2026-05-08) lets bots receive messages from other
bots, and the platform ships no loop guard of its own. core.telegram.org
/api/bots/bot-to-bot ("Loop prevention") requires the BOT to make
bot-message handling terminate predictably via dedupe, per-chat rate
limits and maximum interaction depth, and warns that "failure to handle
loops properly may lead to degraded performance or platform
restrictions".

Hermes had no brake at all: TELEGRAM_ALLOW_BOTS in gateway/authz_mixin.py
was a bare `return True` with no counter, window or cooldown.
TELEGRAM_ALLOW_BOTS=mentions does not help, because when bot A replies to
bot B the reply itself satisfies the mention test, so every turn re-arms
the peer. Observed 2026-08-21: two Hermes bots exchanged 132 messages in
one group before a human intervened.

This adds gateway/bot_loop_guard.py, a thread-safe sliding-window budget
with cooldown and sweeping of stale buckets, wired into
_is_user_authorized. Defaults (20 events / 60 s window / 60 s cooldown)
match the OpenClaw reference implementation.

Two placement details that are easy to get wrong:

- The guard runs BEFORE the TELEGRAM_GROUP_ALLOWED_CHATS shortcut. That
  env var returns True for any sender in an allowlisted chat, bots
  included, ~32 lines before the is_bot block, so a guard placed in the
  is_bot block never executes for the one configuration that needs it.

- The budget is keyed per CONVERSATION, not per (sender, receiver). This
  process only sees inbound messages, so the receiver is constant; keying
  on the pair would give each sender its own budget and N bots would need
  N x budget messages to trip a guard meant to cap the whole exchange.

Installations without ALLOW_BOTS are byte-identical in behaviour: the
guard only runs on the source.is_bot branch with ALLOW_BOTS set to
mentions or all. HERMES_BOT_LOOP_PROTECTION=off is a full kill switch.

Adds tests/gateway/test_bot_loop_guard.py (7 cases), each verified
failing against the tree without this patch.
2026-09-10 10:27:07 -07:00
Teknium e83816a4d1 review-fix(comments): restore lost #NNNN rationale comments across non-test source (mechanical sweep, condensed, code unchanged)
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.
2026-09-03 09:44:26 -07:00
Teknium d03babfddc refactor(gateway): wave-2 final compaction — Platform._missing_ single exit, _cleanup_expired keep-set, expression-form display normalisers and small predicates 2026-09-02 22:42:55 -07:00
Teknium 225605ad7f refactor(gateway): wave-2 pairing/directory/registry compaction — rate-limit load/save helpers, _entry_created_at, shared _read_json, registry _probe + zip-restore, unified _enable_from_env (port-bound pop mode), _transport_owner, _coerce_num 2026-09-02 22:19:43 -07:00
Teknium 48a2007092 refactor(gateway): wave-2 config/authz compaction — shared bool-token parser, _extra_choice, authz phase helpers (_chat_scoped_grant, _legacy_telegram_chat_grant), unified adapter flag/policy readers, tristate display normaliser, hand-compacted field docs 2026-09-02 21:49:33 -07:00
Teknium ef3c794a0e refactor(gateway): extract allowlist principal matching from _is_user_authorized 2026-09-02 19:33:15 -07:00
Teknium 838220617a refactor(gateway): contextlib.suppress for swallow-only try blocks, field-driven SessionResetPolicy.from_dict 2026-09-02 19:17:23 -07:00
Teknium dc40ec89e9 refactor(gateway): collapse authz signatures/ladders, pairing purge/expire, slack resolve branches 2026-09-02 19:09:23 -07:00
Teknium a6bde733fb refactor(gateway): compact config/authz/pairing docs (keep WHYs), reflow env-step data rows 2026-09-02 19:06:10 -07:00
Teknium 24fd09be10 refactor(gateway): unify pairing allowlist writes/json IO, split _build_slack, extract authz own-policy helpers 2026-09-02 18:30:37 -07:00
Teknium 3c7069bdcb refactor(gateway): dead-code removal, helper unification, defensive-layer collapse and rationale-preserving comment compaction across 40 modules
authz_mixin, browser_control_broker, delivery, delivery_ledger, display_config, drain_control,
hosted_room_links/peer/policy_checkpoint, hosted_rooms, platform_registry, relay/__init__,
relay/ws_transport, run.py and slash_commands.py (comments), session_context, session_state,
streaming_tts_consumer, turn_lease and small modules.

- HostedRoomPolicyCheckpoint._apply_event -> per-kind handler table
- WebSocketRelayTransport._handle_frame -> frame-handler table
- GatewayAuthorizationMixin: unified adapter setting/flag/extra readers
- dead symbols removed (verified zero references): RoomLinkProbe/select_room_link,
  relay_bot_username, is_restart_loop_tripped, debug_rows, DeadTargetRegistry.all_dead,
  BrowserControlBroker.detach_owner/_prune_tickets, StreamingTTSConsumer.started/_enqueue_done/
  _iter_stream_chunks/_next_stream_chunk, RecoverableHandleCache.status_for, _auth_env,
  _copy_default_catalog, _parse_timestamp_prefix, _present_* helpers, _send_result_error_kind,
  _truthy_env, SessionFieldView/TurnLeaseTokenView dunder shims, and their orphaned tests.
- lost WHY/invariant text from the earlier compaction restored compactly (541 hunks audited)
2026-09-02 13:30:50 -07:00
fangliquanflq 04c640a183 fix(gateway): honor secondary adapters under profile runtime scope
Multiplex turns enter _profile_runtime_scope, so get_active_profile_name()
equals the secondary profile and the old authz lookup consulted empty
self.adapters. Prefer _profile_adapters before the active-profile shortcut.
2026-09-02 06:48:31 -07:00
Teknium bbb087f3d1 fix(gateway): egress adapter and channel directory no longer follow the per-turn active profile
`_authorization_adapter` compared a stamped profile against
`_active_profile_name()`, which reads the per-turn HERMES_HOME override.
Inside a secondary profile's `_profile_runtime_scope` (cron, restored or
hand-built sources without transport provenance) that reported the
secondary itself, so it was handed the DEFAULT bot for egress instead of
the fail-closed None. Capture the launch identity once in `__init__`
(`_primary_profile_name`) and compare against that; the
`_active_profile_name()` fallback remains for partial fixtures.

`gateway/channel_directory.py` resolved `DIRECTORY_PATH` /
`CHANNEL_ALIASES_PATH` at import time, pinning every multiplexed profile's
directory to whichever home imported the module first. Resolve lazily
from the current home; the module attributes stay as explicit overrides
(tests patch them) and default to None.

Extracted from #87240 (topic-table half handled separately via #76487).

Co-authored-by: cherryb16 <166878179+cherryb16@users.noreply.github.com>
2026-09-02 06:08:09 -07:00
webtecnica c83121cf5f fix(buzz): normalize npub entries in BUZZ_ALLOWED_USERS to hex
Summary:
The gateway's central allowlist check compared the inbound Buzz sender's
64-char hex pubkey against the raw BUZZ_ALLOWED_USERS entries. An operator
who listed only their npub saw every message rejected with
"Unauthorized user: <hex pubkey>" (gateway drops the message). npub
entries are now decoded to hex before the comparison, so npub and hex
forms of the same identity are equivalent.

Root Cause:
The Buzz adapter's own intake check already normalizes npub→hex via
_normalize_user_ref when building _allowed_pubkeys, but the gateway
applies BUZZ_ALLOWED_USERS centrally as well (authz_mixin._is_user_authorized
via the platform registry's allowed_users_env). That central path did a
raw string comparison of the allowlist entries against the hex user_id,
so an npub-only entry never matched.

Change:
- gateway/authz_mixin.py: add a pure-stdlib bech32 npub→hex decoder
  (mirroring plugins/platforms/buzz/adapter.py) and normalize the buzz
  allowlist set in _is_user_authorized: each npub1… entry is decoded and
  its hex form added; hex entries pass through unchanged, so existing
  hex-only allowlists keep working. Comparison stays fail-closed for
  unrelated senders.
- tests/gateway/test_buzz_authz.py: new tests covering npub-only,
  hex-only, mixed, uppercase-npub, and denial of unrelated users, plus
  unit tests for the decoder/helper.

Verification:
- 43 passed (test_buzz_authz.py + test_buzz_adapter.py +
  test_pairing_allowlist_bypass.py)
- 9 passed (test_multiplex_profile_authz.py + test_buzz_websocket.py)

Closes #78428
2026-08-31 07:28:30 -07:00
liuhao1024 aaa5f27d0f fix(buzz): secondary multiplex profiles must not inherit the default profile's env
Under gateway.multiplex_profiles the default profile's YAML-to-env bridge
writes BUZZ_* values into os.environ, and every Buzz read gave that env
precedence over the secondary profile's PlatformConfig — so each secondary
adapter connected as the default identity, watched its channels, and
resolved its credentials file (#98738).

- Add _profile_scoped()/_scoped_platform_setting(): inside a secondary
  profile scope extra is authoritative and env is not consulted (a missing
  key fails closed to its default instead of borrowing the default
  profile's value); single-profile and unscoped/default-profile reads keep
  the legacy env-over-config precedence.
- Apply the scoped read to BuzzAdapter.__init__ (relay, CLI path, channels,
  home channel, poll interval, require_mention, transport, allowed users),
  _resolve_private_key (BUZZ_CREDENTIALS_FILE), validate_config,
  _standalone_send, and check_requirements (which now consults the
  profile's own config.yaml via the scoped home override).
- _env_enablement() returns None inside a profile scope and
  _apply_yaml_config() skips the env bridge there, so the default profile's
  env cannot fabricate Buzz for a profile that never configured it and a
  secondary profile's YAML cannot be pinned into the process env
  (first-writer-wins, #72348 Telegram/Discord mirror).
- Central authorization now consults a plugin platform's live-adapter
  config.extra.allowed_users (gated on the registry entry declaring
  allowed_users_env, with an optional normalize_user_id hook so Buzz npub
  entries match hex-pubkey user ids) — under multiplex only the default
  profile's list ever reached the env var, so listed secondary-profile
  users were default-denied (#82871). Empty/absent lists change nothing;
  default-deny is preserved.
2026-08-31 07:28:30 -07:00
Teknium a0a63a1bc2 fix(gateway): username-based DISCORD_ALLOWED_USERS no longer locks out the operator after one turn
The Discord adapter resolves username allowlist entries to numeric IDs at
connect and mirrors them into os.environ — but the gateway's per-turn .env
hot-reload (load_hermes_dotenv(override=True)) restores the raw usernames
from the file. From the second agent turn onward, _is_user_authorized
compared numeric user_ids against username strings and dropped every
message from the operator as 'Unauthorized user' while the adapter layer
still admitted them (bot reacted, never replied).

Fix: gateway authz unions the adapter's resolved numeric IDs
(DiscordAdapter.resolved_allowlist_user_ids()) into the env-derived
allowlist. Union only fires when an env allowlist is configured (never a
widening; fail-closed branch unchanged), is duck-typed + isinstance-guarded
against mock adapters, and filters non-numeric entries so unresolved
usernames and '*' can't leak through adapter memory.

Live repro: symptom fired on origin/main (authorized=False after reload),
passes with fix; stranger + empty-allowlist + raising-resolver negatives
hold. Sabotage run: incident test fails on unfixed authz_mixin.
2026-08-31 06:28:25 -07:00
Adolanium 2912c36aa4 fix(gateway): stop multiplex allowlist leak and bot-relay python -c injection
_auth_env fell through to os.environ on a scoped miss, so one profile
could inherit another profile's allowlists and allow-all flags.

bot_relay.waiter_command put connection_id into python -c source. A
quote in the id broke the waiter. A crafted id could run extra Python
in the sender gateway.
2026-08-23 20:00:30 -07:00
doncazper 85020f2238 fix(plugins): isolate ownership by profile 2026-08-12 19:13:32 -07:00
GodsBoy c5097da12b fix(gateway): revalidate stored role grants
Gate adapter-provided role authorization during plugin session injection and cover the stale role-only route.
2026-08-12 18:25:33 -07:00
GodsBoy e64fb2b614 fix(review): harden plugin gateway injection 2026-08-12 18:25:33 -07:00
Teknium dbbfcff56d fix(secrets): route authz gate and pairing allowlist reads through the profile secret scope
- gateway/authz_mixin.py: group chat allowlists, {PLATFORM}_ALLOW_BOTS, and
  pairing-mode allowlist presence checks now go through the file's own
  _platform_gate_env helper (scoped-authoritative under multiplex).
- gateway/pairing.py: grant-mirror/revoke allowlist READS go through
  get_secret (Slack pattern for unscoped admin/CLI callers); writes still
  use save_env_value with a TODO for profile-aware writes.
2026-08-02 10:02:33 -07:00
fangliquanflq b35f219aed fix(gateway): apply WhatsApp identity aliases to cloud pairing revoke
Extend phone/JID alias matching to whatsapp_cloud and treat a removed
allowlist env key as empty so sole-entry revoke cannot revive a stale
adapter snapshot.
2026-08-02 11:50:05 +05:30
fangliquanflq ddfc6342ad fix(gateway): revoke WhatsApp sole allowlist entry without restart
Clear live adapter _allow_from on pairing revoke and re-check DM
allowlist authz so sole-entry removal takes effect without restart.
2026-08-02 11:50:05 +05:30
Teknium 81c0691e17 fix(gateway): per-profile Discord/Telegram allow-deny gates under multiplex_profiles
Under gateway.multiplex_profiles, Discord and Telegram authorization gates
(allowed/ignored channels, allowed users/roles, allow-all flags) were read
from process-global os.environ, populated first-writer-wins by the YAML->env
bridge in each adapter's _apply_yaml_config. The first profile to initialize
pinned its allow/deny lists — and its ALLOW_ALL flags — for every other
profile in the process (issue #72348, incl. the Telegram mirror reported in
the thread).

Fix (per-adapter-instance gate reads, whole class):

- gateway/authz_mixin.py: new _platform_gate_env — scope-authoritative gate
  read: under an installed profile secret scope with multiplex active, a
  missing key returns the default instead of falling through to os.environ
  (which may hold another profile's value). Single-profile behavior is
  byte-identical to os.getenv.
- Discord adapter:
  - connect() snapshots all gate env vars (_GATE_ENV_KEYS) inside the owning
    profile's runtime scope into a per-adapter dict; new accessors
    (_get_allowed_channels/_get_ignored_channels/_get_allowed_users/
    _get_allowed_roles/_get_no_thread_channels/_discord_allow_all_users/
    _gateway_allow_all_users/_get_allow_bots) resolve snapshot -> config.extra
    -> scope-aware env, replacing every raw os.getenv gate read: on_message
    channel gates, _is_allowed_user allow-all flags, slash authorization,
    fail-closed diagnostics, missed-message backfill, bot-message gating,
    and _component_check_auth (component buttons).
  - _apply_yaml_config always seeds gate values into PlatformConfig.extra
    (incl. new allowed_roles / allow_all_users keys) and SKIPS the
    process-global env writes when loading a profile-scoped config under
    multiplex; the legacy first-writer env bridge is preserved verbatim for
    single-profile deployments.
  - _resolve_allowed_usernames no longer unconditionally rewrites
    os.environ[DISCORD_ALLOWED_USERS] — under multiplex the resolved IDs stay
    adapter-local (snapshot refresh); single-profile keeps the env rewrite.
- Telegram adapter (mirror of the same class): intake prefilter and
  callback-auth fallbacks, _telegram_auth_env_configured, and the
  allowed/ignored chats-topics-threads getters now read via the scoped gate
  reader; _apply_yaml_config skips authorization env writes for
  profile-scoped loads and seeds free_response_chats/ignored_threads extras.

Regression tests (tests/plugins/platforms/test_discord_gate_isolation.py):
two adapter instances with different allow-lists enforce their OWN lists
order-independently across message, slash, and component gates; negative
allow-all case proves profile A's open-access flag cannot authorize profile
B; username-resolution env-clobber; YAML-bridge seeding/skip matrix; and the
Telegram scoped-reader matrix. Sabotage-verified: reverting either the
Discord snapshot accessors or the Telegram scoped reader fails 12/2 tests
respectively.

Credit: builds on the per-adapter accessor direction of PR #72427
(@JonthanaHanh) and the scope-aware-reader approach validated live on v0.19.0
by @yournetworkplug-ctrl for the Telegram mirror; scope corrections from
jackjin1997's and cal88's analysis in the issue thread (allow-all flags,
unguarded username-resolution env write, per-site channel reads).

Fixes #72348
2026-08-01 10:51:42 -07:00
rhylryan21 4436eacebf fix(gateway): kanban notifier delivery reliability
- honor SendResult(success=False) instead of discarding it, so an adapter
  that REPORTS (not raises) a soft send failure — e.g. the Telegram adapter's
  "Not connected" mid-reconnect — no longer advances the cursor past an
  undelivered event and silently loses the notification. Addresses the
  notifier half of #31901.
- add block_loop_detected to the notifier's TERMINAL_KINDS so a task routed to
  triage for a human decision (re-blocked past the recurrence limit) actually
  pings its subscribers instead of stalling silently.
- raise MAX_SEND_FAILURES 3 -> 12 (~60s at the 5s tick) so a transient
  Telegram/API outage does not permanently unsubscribe a live channel now that
  reported soft-failures also reach this counter.
- route active-profile-stamped subscriptions via the primary adapter on a
  single-profile gateway (self.adapters[platform] when the stamped
  notifier_profile equals the active profile). Related to #56802.

Adds test_kanban_notifier_rewinds_claim_on_reported_send_failure asserting a
reported send failure leaves the event unseen (rewound) rather than consumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 16:14:15 -07:00
Ben Barclay 305a3c7424 fix(relay): restore streaming delivery, Slack command parity, and status clearing (salvage of #69716) (#69747)
* fix(gateway): restore relay streaming delivery

* fix(relay): route Slack parent commands before session gates

* fix(relay): clear Slack typing status after turns

---------

Co-authored-by: Victor Kyriazakos <victor@rocketfueldev.com>
2026-07-23 12:51:13 +10:00
yungchentang ff46376614 fix(gateway): preserve shared route transport adapter 2026-07-22 04:19:10 -07:00
kshitijk4poor 7078430934 fix(telegram): address review findings from PR #67816
- Update test_observed_group_context_preserves_slash_command_text_for_dispatch
  to assert user_id is preserved for COMMAND messages (new correct behavior)
- Add _coerce_allow_set helper to handle both list and comma-separated
  string allowlist inputs (prevents character-by-character iteration bug)
- Include 'channel' in chat_type checks for group-scoped authorization
- Add _telegram_extra fallback for group_allowed_chats (consistent with
  group_allow_from fallback)
- Add AUTHOR_MAP entry for nyaruko@hermes -> tsuk1nose
2026-07-22 12:47:41 +05:30
Nyaruko 45fce38b9e fix(telegram): group authz fallback + command sender identity
- authz_mixin: add config.extra fallback for group_allowed_chats
  when observe-unmentioned mode strips user_id from env-var check
- authz_mixin: check adapter allow_from/group_allow_from for
  user authorization from config.yaml without env vars
- telegram/adapter: separate group_allow_from for group chats
  vs allow_from for DMs
- telegram/adapter: preserve sender source for command messages
  so admin-only slash commands work in groups
- telegram/adapter: add _telegram_extra fallback for
  group_allow_from config reading
2026-07-22 12:47:41 +05:30
Agung Subastian 8fc989b416 fix(gateway): multiplex secret_scope for authz, Slack, webhooks
Secondary profiles under gateway multiplex keep tokens/allowlists in
profile secret_scope, not process os.environ. Auth and Slack were still
reading os.getenv, so Slack on a secondary profile failed allowlist and
socket mode. Webhook deliver also only looked at default adapters.

- Prefer get_secret for allowlists / allow-all flags (authz_mixin)
- Slack app token + allowlist via secret_scope with getenv fallback
- Wrap secondary profile message handlers in _profile_runtime_scope
  before auth runs
- Resolve home-channel env from secret_scope / PlatformConfig
- Webhook deliver falls back to _profile_adapters for target platform
- Template key event_type for webhook prompts
2026-07-16 05:39:58 -07:00
Teknium 249c69b958 fix(gateway): per-profile pairing whitelist isolation in multiplex mode (#53045 salvage) (#59330)
* fix(gateway): per-profile pairing whitelist isolation for multiplex gateways

Pairing approvals are stored per profile (profiles/<name>/pairing/) and
authz routes pairing checks through the serving profile's store, so one
profile's approved users no longer authorize against every other
profile's whitelist in multiplex mode.

The global store remains for the hermes pairing CLI and single-profile
gateways; unregistered/unstamped sources fall back to it, preserving
existing behavior.

Salvaged from PR #53045 (pairing half). The SOUL.md half was dropped:
the agent turn already runs inside _profile_runtime_scope on main, so
load_soul_md() resolves per-profile without changes.

Original work by @soddy022.

* ci: redispatch after arm64 docker dashboard-slot flake (unrelated to this PR)

---------

Co-authored-by: soddy022 <290613374+soddy022@users.noreply.github.com>
2026-07-07 18:25:52 +10:00
teknium1 ab70551b3d fix(gateway): fail-closed adapter resolution for unregistered secondary profiles
Follow-up to the routing sweep: when a stamped secondary profile has no
_profile_adapters entry (adapter failed to connect / was refused), return
None instead of falling back to the default profile's adapter — the
fallback sends replies out the wrong bot, which is the exact leak class
this cluster fixes. Also restores main's deliberate fail-fast on
port-binding platforms in secondary profiles (the cherry-picked commit
had softened it to silent force-disable).

Co-authored-by: ManniBr <m888.braun@hotmail.com>
2026-07-05 21:48:59 -07:00
Teknium 1bfe08145c fix(gateway): pairing is a grant that syncs to the allowlist (#23778) (#56381)
Consolidates the pairing/allowlist authorization model. Reverses the
read-side AND-ing from #56346 (which made a paired user require ALSO
being in the allowlist) and restores pairing as a first-class grant:

- authz_mixin: a pairing-store entry authorizes regardless of the
  allowlist (union). approve_code is reachable only by the trusted
  operator (CLI / authenticated dashboard), never by an inbound sender,
  so it is not an attacker-controlled path — the #23778 bypass was the
  inbound message/approval-button gate, fixed separately.
- pairing: when an allowlist IS already configured for the platform,
  operator approval also appends the user to that allowlist env var
  (option i) and revoke removes them, keeping a single operator-visible,
  editable source of truth instead of an opaque approved.json. On an
  open gateway (no allowlist) approval is a no-op on the env var so we
  never silently lock an open gateway; the pairing store remains the
  grant record, honored by the union.
- auto-resume authz (0de67ad60) now honors paired users automatically
  via the same union — a legitimately-paired session survives restart.

Replaces the now-incorrect AND-ing tests with union + mirror + revoke
coverage. E2E verified: locked-gateway approve/revoke round-trips
through the allowlist; open-gateway approval stays open.
2026-07-01 05:31:15 -07:00
ygd58 50aaa426c1 fix(gateway): pairing store cannot bypass configured allowlist
A user who tapped Always on an approval button gets a pairing-store entry.
_is_user_authorized() checked the pairing store BEFORE the allowlist and
returned True unconditionally, so a paired-but-not-allowed user permanently
bypassed TELEGRAM_ALLOWED_USERS (or equivalent) even after being removed from
the allowlist (#23778).

Record pairing membership but only honor it in the no-allowlist branch. When
an allowlist IS configured, the paired user must appear in the canonical
allowed_ids set (the same set that resolves WhatsApp aliases, SimpleX names,
group allowlists, and the '*' wildcard), so pairing grants no extra access.

Cherry-picked/rebased from #47736 (#23805) by ygd58; membership check rewritten
to reuse the existing allowlist logic. Adds regression tests.
2026-07-01 04:56:25 -07:00
Tao Chen d3c8667462 fix(slack): authorize bot/workflow senders before the no-user-id guard
Slack Workflow Builder posts (and other app/bot messages) arrive as
subtype=bot_message with user=None. _is_user_authorized rejected them at
the `if not user_id: return False` guard, which runs *before* the #4466
{PLATFORM}_ALLOW_BOTS bypass — so @mentioning the bot from a Slack
workflow silently did nothing, even with SLACK_ALLOW_BOTS (or
SLACK_ALLOW_ALL_USERS) set. The chat-scoped allowlist for Telegram/QQ
already runs before that guard for the same reason (channel broadcasts
with no from_user); Slack was both missing from the bot-bypass map and
had the bypass running too late.

- gateway/authz_mixin: move the {PLATFORM}_ALLOW_BOTS bypass ahead of the
  no-user-id guard and add Platform.SLACK -> SLACK_ALLOW_BOTS.
- plugins/platforms/slack/adapter: set is_bot=True on inbound
  bot_message events so the gateway can identify workflow/app senders
  (they carry no user_id to match against the allowlist).

Tested: new tests/gateway/test_slack_bot_auth_bypass.py plus the existing
Discord/Feishu bot-auth and gateway authz/gating suites all pass.
2026-07-01 16:32:32 +05:30
teknium1 d1d1d81900 fix(gateway): repair sibling tests + harden _adapter_for_source after fail-closed flip
Follow-up to the salvaged fail-closed defaults. The own-policy default flip
(open -> pairing) and the email dispatch-level deny broke sibling tests
across the suite that relied on the old fail-open behavior:

- test_email.py: dispatch-mechanics tests now opt into EMAIL_ALLOW_ALL_USERS
  (they test formatting/attachments/threading, not authz); the two auth
  contract tests are rewritten to assert the new fail-closed behavior
  (no allowlist + no allow-all => sender dropped at the adapter).
- test_whatsapp_cloud.py / test_whatsapp_formatting.py / test_whatsapp_from_owner.py:
  autouse fixture opts into WHATSAPP_ALLOW_ALL_USERS so dm_policy: open
  dispatch-mechanics tests still flow (open now requires an explicit
  allow-all opt-in, SECURITY.md 2.6).
- _adapter_for_source: use getattr for source.platform/profile so bare
  SimpleNamespace test fixtures without .profile don't crash the busy/queue
  ingress path (AGENTS.md pitfall #17).

Full tests/gateway/ + yuanbao pipeline: 8555 passed, 0 failed.
2026-07-01 03:56:28 -07:00
SahilRakhaiya05 bb304b4914 fix(gateway): fail-closed external-surface defaults + profile-aware multiplex authz
Aligns runtime behaviour with SECURITY.md 2.6: externally reachable
messaging adapters must fail closed unless access is explicitly
configured. Closes the confirmed multiplex authorization bypass a
secondary profile's open dm/group policy no longer inherits the default
profile's allowlist trust.

- Own-policy adapters (WhatsApp, WeCom, Weixin, QQBot, Yuanbao) default
  dm_policy/group_policy to pairing/allowlist instead of open; open now
  requires an explicit GATEWAY_ALLOW_ALL_USERS or per-platform allow-all.
- Startup guard (_own_policy_open_startup_violation) refuses to boot when
  an enabled adapter is open without the allow-all opt-in; the guard now
  runs for every secondary profile in multiplex mode too.
- Profile-aware own-policy authorization: _authorization_adapter /
  _adapter_for_source resolve the live adapter via SessionSource.profile,
  so _is_user_authorized and the ingress/pairing/busy/queue paths read the
  originating profile's adapter policy, not the default profile's.
- Fail-closed intake for Email, Feishu P2P, and Discord (blank-principal
  denial, empty-allowlist deny, missing-interaction.user deny).

Salvaged from #44073 (external-surface hardening), split into a focused
gateway-authz PR per maintainer request. Follow-up fix by Hermes Agent:
the Discord slash-auth channel bypass now matches DISCORD_ALLOWED_CHANNELS
by the same name-inclusive keys (id + name + #name + parent) the on_message
scope gate uses, so a name-form channel allowlist authorizes slash
interactions consistently (was id-only, breaking #name matching).

Co-authored-by: Hermes Agent <agent@nousresearch.com>
2026-07-01 03:56:28 -07:00
sweetcornna fc70d023d8 fix(telegram): apply bot auth policy to Telegram sources
# Conflicts:
#	gateway/config.py
2026-06-28 00:57:03 -07:00
Ben Barclay 72ae163250 fix(relay): authorize relay-delivered events by delivery, not source.platform (#52306)
* fix(relay): authorize relay-delivered events by delivery, not source.platform

The #52190 upstream-authz fix keyed _is_user_authorized off
source.platform via _adapter_authorization_is_upstream(source.platform).
But a relay *message* inbound carries the UNDERLYING platform
(source.platform == discord/telegram/...), NOT Platform.RELAY, because
ws_transport._event_from_wire maps the connector's wire payload
(platform="discord") straight onto SessionSource for session-keying and
egress. The relay adapter is registered only under Platform.RELAY, so
adapters.get(Platform.DISCORD) misses, the trusted-upstream branch is
skipped, and the user hits the env-allowlist default-deny:

    WARNING gateway.run: Unauthorized user: <id> (<name>) on discord

(Live staging bug: alpha tester linked successfully, then every
follow-up DM was silently dropped.)

Fix: the authentic trust signal is that the event was delivered over the
per-instance-authenticated relay WS, not which platform it underlies. Add
a wire-INVISIBLE SessionSource.delivered_via_upstream_relay flag, stamped
by the relay transport in _event_from_wire, and authorize on it. The flag
is excluded from to_dict/from_dict so a peer can neither forge it across
the wire nor have it restored from persistence. The existing adapter-flag
check is retained for events whose source.platform IS Platform.RELAY
(interaction-passthrough). A direct Discord event on a multiplexing
gateway (direct + relay adapters) is unmarked and still default-denies.

* fix(relay): use identity check on delivery marker to avoid MagicMock fail-open

A MagicMock() source (used by test_signal.py and other gateway tests) auto-
vivifies source.delivered_via_upstream_relay as a truthy Mock, which a bare
truthiness check would treat as authorized — flipping
test_signal_in_allowlist_maps from False to True. The marker is a real bool on
SessionSource, so check 'is True' explicitly: refuses to authorize any non-bool
stand-in, defensive against accidental fail-open.
2026-06-25 14:21:09 +10:00
Ben d335164833 fix(relay): authorize relay inbound via connector-enforced upstream authz
A hosted instance fronted by the Team Gateway connector dropped EVERY relay
message as "Unauthorized user" and the agent never replied — despite the
message routing correctly through the connector to the instance.

Root cause: gateway authorization (_is_user_authorized) had no notion of
upstream-enforced authz. Platform.RELAY matches no {PLATFORM}_ALLOWED_USERS
allowlist and isn't in the HA/WEBHOOK always-authorized set, so a relay user
with no env allowlist configured hit the default-deny ("No user allowlists
configured. All unauthorized users will be denied."). The message was received,
then silently denied before reaching the agent.

This is incorrect for relay: the connector authenticates the gateway's WS with
a per-instance secret and performs owner-only author-binding resolution BEFORE
delivering. A message only reaches this gateway because the connector resolved
it to THIS instance's bound user (user_instance_binding), keyed on the author id
the connector OBSERVED off the event — never a gateway claim. The authorization
decision is already made by a trusted, authenticated upstream; there is no local
RELAY_ALLOWED_USERS allowlist to consult, and default-denying for its absence is
the bug.

Fix: add a generic BasePlatformAdapter.authorization_is_upstream capability
(default False) that the relay adapter overrides to True, plus a dedicated
trusted branch in _is_user_authorized that honors it. This is delegation to a
trusted upstream, NOT a fail-open: it fires only for an adapter that explicitly
declares the flag; every direct network-exposed adapter leaves it False and the
env-allowlist default-deny (SECURITY.md §2.6) is unchanged. Distinct from
enforces_own_access_policy, which mirrors a LOCAL config-driven allowlist —
this delegates to an authenticated upstream's decision.

Tests: behavior contract that the base defaults False, the relay adapter
declares True, a relay user (group + DM) is authorized with no env allowlist,
and crucially a non-upstream adapter with no allowlist still default-denies
(guards against the fix becoming a blanket fail-open). 6 new tests; relay +
authz + config-policy suites green (134 + 90).

Found via live staging debug of the Discord self-serve onboarding flow.
2026-06-25 10:06:21 +10:00
Shannon Sands 5dae502b86 Address email pairing review feedback 2026-06-21 22:43:57 -07:00
Shannon Sands 2455e1801b Make email pairing opt-in 2026-06-21 22:43:57 -07:00