Skip to content

fix(send_message): route bare in-turn target to current channel, not home - #71

Merged
Kyzcreig merged 1 commit into
mainfrom
fix/send-message-origin-leak
Jun 22, 2026
Merged

Kyzcreig merged 1 commit into
mainfrom
fix/send-message-origin-leak

Conversation

@Kyzcreig

Copy link
Copy Markdown
Collaborator

Problem

A send_message / react call with no explicit chat target silently fell back to the platform's global home channel. When an agent posted a mid-turn update from a non-home channel (e.g. a Discord project channel), the message leaked into the home channel — sometimes a different platform (Telegram DM) entirely.

Real incident (2026-06-21): a long agent turn in Discord #instacart called send_message with a bare target; the progress checkpoint surfaced in the Discord home channel #hermes instead. agent.log shows the exact two-step signature — send_message error "Both 'target' and 'message' are required" then send_message completed (200 chars) — and the leaked text landed in the home-channel session at the same millisecond.

Root cause

_handle_send (and the identical sibling _handle_react) default an unspecified chat_id to config.get_home_channel(platform). That is correct for cron/CLI/standalone, but inside a live messaging turn the destination should be the channel the turn is happening in. target is optional in the schema ("required": []) and the description literally said "'platform' (uses home channel)", so a bare target was a documented path straight to the leak.

Fix

A single tri-state resolver, _resolve_send_target(platform_name):

  • ORIGIN — a real messaging origin is bound → route to the turn's own channel (read from the task-local HERMES_SESSION_* contextvars the gateway binds per turn).
  • IN_TURN_UNRESOLVABLE — messaging origin bound but not resolvable for the requested platform → actionable error, never home.
  • NOT_IN_TURN — no messaging origin (cron / CLI / TUI / desktop / ACP) → existing home behavior, unchanged.

The home-channel lookup is reachable only on the NOT_IN_TURN branch, so a live messaging turn can never silently hit home (structural, not emergent).

_has_messaging_origin() gates on an actually-bound messaging platform (approval._get_session_platform() truthiness, minus cron) — deliberately narrower than _is_gateway_approval_context(), because the TUI/desktop/ACP gateways set HERMES_GATEWAY_SESSION without binding a messaging platform; using the broad helper would turn their legitimate bare→home sends into errors.

Both _handle_send and _handle_react route through the shared resolver (whole bug class, not just the reported site). Schema descriptions (top-level + target property) updated to match; origin thread id is preserved; a logger.info records origin-routed sends.

Reproduction

# pre-fix: in a Discord turn on channel A with home=B
send_message(target="discord", message="x")   -> delivered to B (LEAK)
# post-fix
send_message(target="discord", message="x")   -> delivered to A

Tests

tests/tools/test_send_message_origin.py — 9 cases, real _handle_send/_handle_react path, only the network send + config stubbed:

  • bare send in a live turn → origin channel (the regression guard for the leak)
  • bare react in a live turn → origin channel (sibling path)
  • no context (cron/CLI) → home (unchanged)
  • HERMES_GATEWAY_SESSION set, no platform bound (TUI/desktop/ACP) → home, not an error
  • cron (HERMES_CRON_SESSION) → home
  • unresolvable in-turn origin → error, not home
  • explicit cross-channel target unchanged
  • resolver reads task-local session context
  • origin thread id preserved

All 9 pass; ruff clean; no regression in the existing send_message suites.

Scope / non-goals

  • Cron/CLI/TUI/desktop/ACP home-default delivery is preserved exactly.
  • delegate_task subagent bare sends are out of scope: contextvars don't cross the executor boundary, so a subagent has no bound messaging origin → falls to home (today's behavior, never worse). The reported leak was a main-session send.

Spec: docs/specs/PRD-send-message-origin-leak.md (2 Opus review passes; Pass-2 caught and fixed the TUI/desktop over-broad-gate blocker).

…home

A send_message/react call with no explicit chat target silently fell back
to the platform global home channel. An agent posting a mid-turn update from
a non-home channel (e.g. a Discord project channel) leaked the message into
the home channel — sometimes a different platform (Telegram DM) entirely.

Root cause: _handle_send / _handle_react default an unspecified target to
config.get_home_channel(platform). That is correct for cron/CLI/standalone,
but inside a live messaging turn the destination should be the channel the
turn is happening in.

Fix: a single tri-state resolver (_resolve_send_target) gated on whether a
real MESSAGING origin is bound for this turn (_has_messaging_origin: a
non-empty session platform, excluding cron). Inside a messaging turn a bare
target resolves to the current channel (HERMES_SESSION_* task-local
contextvars) or returns an actionable error — it can never reach the home
channel. Non-messaging contexts (cron/CLI/TUI/desktop/ACP, which set
HERMES_GATEWAY_SESSION without binding a platform) keep the existing home
default. Schema descriptions updated to match; thread id preserved.

Repro (pre-fix): in a Discord turn on channel A with home=B, call
send_message(target="discord", message="x") -> delivered to B.
Post-fix: delivered to A.

Tests: tests/tools/test_send_message_origin.py (9 cases: origin send +
react, no-context home, TUI/gateway-session home, cron home, unresolvable
error, explicit unchanged, contextvar read, thread fidelity).

Scope: out of scope is delegate_task subagent bare sends (contextvars do
not cross the executor boundary; they fall to home = todays behavior).
@Kyzcreig
Kyzcreig force-pushed the fix/send-message-origin-leak branch from d80ec73 to f9af6b3 Compare June 22, 2026 00:52
@github-actions

Copy link
Copy Markdown

🔎 Lint report: fix/send-message-origin-leak vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 11252 on HEAD, 11244 on base (🆕 +8)

🆕 New issues (3):

Rule Count
unresolved-attribute 2
unresolved-import 1
First entries
tests/tools/test_send_message_origin.py:29: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/tools/test_send_message_origin.py:291: [unresolved-attribute] unresolved-attribute: Attribute `kwargs` is not defined on `None` in union `_Call | None`
tests/tools/test_send_message_origin.py:290: [unresolved-attribute] unresolved-attribute: Attribute `args` is not defined on `None` in union `_Call | None`

✅ Fixed issues: none

Unchanged: 5874 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a silent home-channel leak where a bare-platform send_message or react call during a live messaging turn routed to the global home channel instead of the channel the turn was happening in. It introduces a shared tri-state resolver (_resolve_send_target) used by both _handle_send and _handle_react.

  • ORIGIN (live messaging turn, same platform): routes to the turn's own chat_id/thread_id read from task-local ContextVars.
  • IN_TURN_UNRESOLVABLE (live turn but different platform requested): returns an actionable error, never falls through to home.
  • NOT_IN_TURN (cron, CLI, TUI, desktop, ACP): preserves existing home-channel behavior unchanged.
  • Nine regression tests cover the primary fix and all boundary contexts.

Confidence Score: 4/5

The core fix is structurally sound; one typo in the secondary fallback branch silently drops thread IDs when that path fires.

The primary routing logic is correct and well-tested. However, the session-key fallback inside _interactive_origin (line 211) uses parsed.get(session_id) — a key that _parse_session_key never produces — instead of parsed.get(thread_id). When that fallback fires, the thread ID is silently dropped and the message lands at channel root rather than the correct thread.

tools/send_message_tool.py line 211: wrong dict key in the fallback branch of _interactive_origin.

Important Files Changed

Filename Overview
tools/send_message_tool.py Adds tri-state origin resolver; primary path correct, but session-key fallback branch has a wrong dict key (session_id vs thread_id) that silently drops thread IDs.
tests/tools/test_send_message_origin.py 9 regression tests covering the fix and boundary cases; fallback session-key parse path with thread ID is not exercised.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["send_message / react (no explicit chat_id)"] --> B["_resolve_send_target(platform)"]
    B --> C{"_has_messaging_origin()?"}
    C -- "False (cron/no platform)" --> D["NOT_IN_TURN"]
    C -- "True (platform bound)" --> E["_interactive_origin(platform)"]
    E --> F{"session platform == requested?"}
    F -- "Yes + chat_id set" --> G["ORIGIN: deliver to turn channel"]
    F -- "No / chat_id empty" --> H["Parse session_key"]
    H --> I{"key platform matches?"}
    I -- "Yes + chat_id in key" --> G
    I -- "No" --> J["return None"]
    J --> K["IN_TURN_UNRESOLVABLE: error"]
    D --> L["home channel from config"]
    L --> M["Deliver to home"]
    G --> N["Deliver to origin channel"]
    K --> O["Return error JSON"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["send_message / react (no explicit chat_id)"] --> B["_resolve_send_target(platform)"]
    B --> C{"_has_messaging_origin()?"}
    C -- "False (cron/no platform)" --> D["NOT_IN_TURN"]
    C -- "True (platform bound)" --> E["_interactive_origin(platform)"]
    E --> F{"session platform == requested?"}
    F -- "Yes + chat_id set" --> G["ORIGIN: deliver to turn channel"]
    F -- "No / chat_id empty" --> H["Parse session_key"]
    H --> I{"key platform matches?"}
    I -- "Yes + chat_id in key" --> G
    I -- "No" --> J["return None"]
    J --> K["IN_TURN_UNRESOLVABLE: error"]
    D --> L["home channel from config"]
    L --> M["Deliver to home"]
    G --> N["Deliver to origin channel"]
    K --> O["Return error JSON"]
Loading

Reviews (1): Last reviewed commit: "fix(send_message): route bare in-turn ta..." | Re-trigger Greptile

if parsed and (parsed.get("platform") or "").strip().lower() == want:
chat_id = (parsed.get("chat_id") or "").strip()
if chat_id:
thread_id = (parsed.get("session_id") or "").strip() or None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Wrong key name in the session-key fallback path — "session_id" does not exist in the dict returned by _parse_session_key. That function returns {"platform", "chat_type", "chat_id"} and optionally "thread_id" (only for dm/thread chat types). Using "session_id" always evaluates to None, so the fallback silently drops the thread ID every time it fires, causing messages to land at channel root instead of the correct thread.

Suggested change
thread_id = (parsed.get("session_id") or "").strip() or None
thread_id = (parsed.get("thread_id") or "").strip() or None

Comment on lines +228 to +258
# --------------------------------------------------------------------------- #
# AC5 — explicit cross-channel target unchanged
# --------------------------------------------------------------------------- #
def test_explicit_target_unchanged(_clear_session):
config = _config_with_home()
# Even inside a discord turn, an explicit chat id is honored verbatim.
tokens = set_session_vars(platform="discord", chat_id=ORIGIN_CHAT)
try:
with patch("gateway.config.load_gateway_config", return_value=config), \
patch("tools.interrupt.is_interrupted", return_value=False), \
patch("model_tools._run_async", side_effect=_run_async_immediately), \
patch("tools.send_message_tool._send_to_platform",
new=AsyncMock(return_value={"success": True})) as send_mock, \
patch("gateway.mirror.mirror_to_session", return_value=True):
result = _send("discord:7777000011112222")
finally:
clear_session_vars(tokens)

assert result["success"] is True
assert send_mock.await_args.args[2] == "7777000011112222"


# --------------------------------------------------------------------------- #
# AC7 — origin resolution reads task-local session-context (not a global)
# --------------------------------------------------------------------------- #
def test_origin_resolution_reads_session_context(_clear_session):
from tools.send_message_tool import _resolve_send_target, _SEND_TARGET_ORIGIN

tokens = set_session_vars(platform="discord", chat_id=ORIGIN_CHAT)
try:
state, chat_id, _thread = _resolve_send_target("discord")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 AC7b covers the primary path, not the fallback

test_origin_preserves_thread_id sets both chat_id=ORIGIN_CHAT and thread_id=ORIGIN_THREAD via set_session_vars, so it always hits the primary branch of _interactive_origin (lines 196-200). The session_id / thread_id typo in the fallback branch (reached when the session vars have a platform but an empty chat_id, requiring resolution through _parse_session_key) is never exercised.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@Kyzcreig
Kyzcreig merged commit 78d2c65 into main Jun 22, 2026
44 of 45 checks passed
@Kyzcreig
Kyzcreig deleted the fix/send-message-origin-leak branch June 22, 2026 01:05
Kyzcreig added a commit that referenced this pull request Jun 22, 2026
…me (v2) (#83)

* fix(send_message): route subagent bare sends to parent origin, not home

Follow-up to #71. After #71 fixed the main-turn bare-target home-channel
leak, the leak recurred from delegate_task SUBAGENTS: a child runs in a bare
ThreadPoolExecutor worker with empty contextvars, so a subagent calling
send_message/react with a bare target saw no session origin and fell back to
the global home channel (a #tdd progress update landed in #hermes).

Fix: dedicated routing-only send-origin contextvars (_SEND_ORIGIN_*) in
gateway/session_context.py, read ONLY by the send resolver. The delegate
child-run wrapper (_run_with_thread_capture) binds them from the parent
origin captured at spawn (_blackbox_parent_*) and clears them in a finally.
This propagates via the worker-thread copy_context() into the child tool-call
threads where send_message actually executes.

Crucially does NOT reuse HERMES_SESSION_PLATFORM (which drives approval
gating, skill-disable, TTS, prompt-cache) — the child keeps its subagent
identity for all of those; only send routing sees the new vars.

Also: grandchild chaining (orchestrator subagent spawning a grandchild) —
the spawn-capture falls back to get_send_origin() so origin is transitive
through nesting; and a NOT_IN_TURN-despite-session-key tripwire warning to
catch any remaining/unknown leak path (instrument-first for the unproven
main-turn case).

Tests: tests/tools/test_subagent_send_origin.py (14 cases): parent-origin
routing for send+react, resolution order (gateway turn wins), no-origin
falls to home, concurrent-children isolation, nestable clear (grandchild),
instrumentation warns/silent-on-cron/never-raises, and the security
non-regression (send-origin does not flip approval context). Seam proven:
with the resolver blind to send-origin, the same call returns NOT_IN_TURN
(home leak). 165 passed in the changed-module suites (1 pre-existing
heartbeat-timing flake unrelated).

Spec: docs/specs/PRD-send-message-origin-leak-v2.md (2 Opus review passes;
Pass-1 caught that the naive _SESSION_PLATFORM-reuse design would flip
subagent approval gating; Pass-2 caught the grandchild chain gap).

* docs(send_message): PRD v2 — subagent origin-leak fix (2 Opus passes)

* fix(send_message): require platform AND chat in _has_messaging_origin

Greptile P2: _has_messaging_origin checked only get_send_origin()[0]
(platform) while _interactive_origin requires both platform and chat_id. A
platform-only send-origin (e.g. set_send_origin("discord","")) would flip
_has_messaging_origin True but _interactive_origin None -> IN_TURN_UNRESOLVABLE
hard error instead of falling closed to home. Require both, matching the
resolver contract. Regression test added.

---------

Co-authored-by: Apollo <apollo@daemonarchy.local>
Kyzcreig pushed a commit that referenced this pull request Jun 30, 2026
…y upstream parity merge

The 2026-06-29 upstream parity merge (PR #119) took upstream's toolsets.py
structure, which silently dropped two fork-only toolsets:

  * messaging -> send_message (agent-callable; the fork's origin-routing work
    #71/#83 is built on it). Upstream deliberately omits an agent-callable
    send_message; this fork re-enables it.
  * moa -> mixture_of_agents (tools/mixture_of_agents_tool.py, fully deleted).

Post-merge resolve_toolset('messaging')/('moa') returned [] with no error, so
on deploy Apollo would silently lose both tools across cli/telegram/discord.
Caught by the config-migration dry-run's post-migration toolset validation.

Restores:
  - toolsets.py: messaging + moa entries (marked FORK-CUSTOM so a future merge
    preserves them)
  - tools/mixture_of_agents_tool.py (self-registers moa via registry.register)
  - agent/display.py: mixture_of_agents formatter + arg-key
  - model_tools.py: moa_tools legacy alias
  - acp_adapter/tools.py: mixture_of_agents in polished ACP tools
  - hermes_cli/tools_config.py: messaging+moa UI rows, moa default-off + env req
  - hermes_cli/config.py: OPENROUTER_API_KEY tools list (+ mixture_of_agents)
  - hermes_cli/tips.py: mixture_of_agents tip

Adds tests/agent/test_fork_custom_toolsets.py — a HARD regression gate that fails
CI if either fork toolset is dropped again (proven: 6/6 pass restored, 6/6 fail
on the broken pre-restore tree). Hardened against the editable-install live-tree
leak so a deleted module can't false-pass via a sibling install.
Kyzcreig added a commit that referenced this pull request Sep 25, 2026
Kyzcreig pushed a commit that referenced this pull request Sep 28, 2026
Only psutil.NoSuchProcess means gone/no-op; AccessDenied et al. now refuse
the signal in all three psutil probes. Red on base, 34/34 guard tests green.
Kyzcreig pushed a commit that referenced this pull request Sep 28, 2026
…dren (C5 #71 CI follow-up)

CI slice 9/16 red: test_process_probe_failure_never_authorizes_reclaim[psutil-missing]
stubs sys.modules['psutil']=None, so psutil.Process raises AttributeError on the
test's own spawned child and the guard refused its cleanup kill. For pids on the
spawn/initial-children record only AccessDenied fails closed; broken probe
machinery trusts the record. Parent-chain branch still fails closed on anything.
Red locally before, 45/45 (stall + guard self-test) after.
Kyzcreig pushed a commit that referenced this pull request Sep 28, 2026
Only psutil.NoSuchProcess means gone/no-op; AccessDenied et al. now refuse
the signal in all three psutil probes. Red on base, 34/34 guard tests green.
Kyzcreig pushed a commit that referenced this pull request Sep 28, 2026
…dren (C5 #71 CI follow-up)

CI slice 9/16 red: test_process_probe_failure_never_authorizes_reclaim[psutil-missing]
stubs sys.modules['psutil']=None, so psutil.Process raises AttributeError on the
test's own spawned child and the guard refused its cleanup kill. For pids on the
spawn/initial-children record only AccessDenied fails closed; broken probe
machinery trusts the record. Parent-chain branch still fails closed on anything.
Red locally before, 45/45 (stall + guard self-test) after.
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