Skip to content

fix(relay): scope interactive prompt resolution to the session owner - #72008

Open
necoweb3 wants to merge 2 commits into
NousResearch:mainfrom
necoweb3:fix/relay-prompt-owner-scope
Open

necoweb3 wants to merge 2 commits into
NousResearch:mainfrom
necoweb3:fix/relay-prompt-owner-scope

Conversation

@necoweb3

Copy link
Copy Markdown
Contributor

Summary

The relay adapter resolves an approval / slash-confirm / clarify button click
in _consume_prompt_response, which runs before handle_message (see
_on_passthrough). The normal _is_user_authorized gate therefore never runs
for a consumed prompt, and the resolver keys only off the button's
prompt_id → session_key — never the clicker's identity.

In a per-user channel session (group_sessions_per_user, the default), any
other member of the channel who can see the buttons can click "✅ Always" and
resolve — and permanently allowlist — another user's dangerous-command
approval (CWE-639, IDOR).

Problem

_on_passthrough (Discord interactions over the relay/connector passthrough
plane) decodes a component press into a MessageEvent and calls
_consume_prompt_response(event) before handle_message:

if await self._consume_prompt_response(event):
    return
await self.handle_message(event)

_consume_prompt_response looks the prompt up by prompt_id, reads the stored
session_key, and calls resolve_gateway_approval(session_key, choice) (and
the slash-confirm / clarify equivalents). The pending-prompt state minted in
send_exec_approval is only {"session_key", "chat_id"} — no owner — and
event.source.user_id (the clicker, taken from the raw Discord interaction
body in _discord_interaction_to_event) is never compared against it.

Because resolution precedes handle_message, the click never reaches
_is_user_authorized; the only thing standing between a co-member and another
user's approval is an 8-hex prompt_id that is printed into the button every
channel member can see.

Exploit (default config, Discord over the relay, a shared guild channel where
group_sessions_per_user is on so each member has their own session):

  1. Victim's agent hits a dangerous command; send_exec_approval posts
    "⚠️ Command Approval Required" with Once / Session / Always / Deny buttons
    into the channel, bound to the victim's session_key.
  2. A different member clicks "✅ Always". The connector verifies the Discord
    signature and forwards the interaction on the passthrough plane.
  3. _on_passthrough → _consume_prompt_response resolves
    resolve_gateway_approval(victim_session, "always") — approving, and
    permanently allowlisting, the victim's command. The clicker's identity is
    discarded.

The same gap applies to the slash_confirm and clarify arms.

Prior art shows this class is in scope for the project: the native adapters
already guard it — qqbot's _is_authorized_interaction_for_session
(operator == session_user) and whatsapp_cloud's
_is_interactive_sender_authorized — and #41226 fixed the identical
"any channel member can click Approve" bug for the Slack / Feishu / Discord
native adapters. The relay passthrough path was never covered (#41226 and
the open delegation PR #47863 both leave gateway/relay/adapter.py untouched).

Fix

Before resolving, require the clicker to own the session. Re-derive the
caller's session key exactly as handle_message does — the same
build_session_key(event.source, …) with the same config — and require an
exact match:

  • A shared session (group_sessions_per_user=False, or a shared thread)
    collapses every member's source to the same key, so all members stay allowed
    — no regression to legitimate shared-channel approvals.
  • A per-user key carries the participant id, so only its owner matches.
  • DMs are 1:1 (their key has no participant id) and fail open.
  • An empty session_key fails open; a key-derivation error fails closed.

The check peeks (it does not pop) so a rejected stray click can't evict a
still-pending prompt from under its real owner — the legitimate owner can still
answer afterward. A rejected click is consumed (returns True) so the
command-shaped text isn't re-dispatched as the clicker's chat.

This mirrors the native adapters' owner check without a fragile session-key
parser, and without touching the generic run.py approval path.

Scope

  • gateway/relay/adapter.py — new _caller_owns_prompt_session helper +
    a peek-and-check guard at the top of _consume_prompt_response (covers all
    three prompt kinds: exec approval, slash-confirm, clarify).
  • tests/gateway/relay/test_relay_interactive.py — 3 regression tests.

No signature changes; no change to run.py or the native adapters.

Testing

New tests (channel sessions):

  • test_prompt_response_rejects_non_owner_in_per_user_channel — a co-member's
    "always" click does not call resolve_gateway_approval, the event is
    consumed, and the prompt is left pending for its real owner.
  • test_prompt_response_allows_owner_in_per_user_channel — the owner's click
    resolves as before.
  • test_prompt_response_shared_channel_allows_any_member — with
    group_sessions_per_user=False, any member resolves (shared session
    preserved).

The existing interactive tests are unchanged and still pass: they use DM
sources, which the DM fast-path leaves untouched.

@alt-glitch alt-glitch added type/security Security vulnerability or hardening comp/gateway Gateway runner, session dispatch, delivery platform/discord Discord bot adapter area/auth Authentication, OAuth, credential pools sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data P2 Medium — degraded but workaround exists needs-repro Bug needs reproduction steps labels Jul 26, 2026
@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

The change blocks the reported cross-user approval attack on same-platform per-user channel events, preserves the pending prompt after a rejected click, and intentionally permits shared-session members. However, its exact full-session-key comparison breaks the real Discord relay callback path: ordinary relay messages retain source.platform=discord, while Discord component callbacks are normalized by _discord_interaction_to_event with source.platform=relay. The key derived for the legitimate owner therefore never equals the pending Discord session key, so exec approvals, slash confirmations, and clarifications remain pending and cannot be resolved through their native buttons. This deterministic regression should be fixed and covered with an end-to-end-shaped test before merge.

  • [P1] Discord relay prompt buttons reject the legitimate session owner (gateway/relay/adapter.py:1374)
    At line 1374 the new validator derives a complete session key from the callback source and line 1385 requires exact equality with the pending key. Normal connector inbound events preserve the underlying platform in ws_transport._event_from_wire, so a Discord conversation is keyed as agent:main:discord:channel:.... Discord button callbacks instead traverse _on_passthrough and _discord_interaction_to_event, which deliberately sets Platform.RELAY, producing agent:main:relay:channel:... for the same guild, channel, and user. The authorization check consequently consumes and rejects every legitimate Discord native prompt click while leaving the prompt pending. The added positive test uses platform="relay" for both the prompt owner and callback, so it does not exercise this production split. This affects all three consumers guarded here: dangerous-command approvals, slash confirmations, and clarifications.
    Remediation: Authorize against a canonical identity that is stable across normalized inbound and Discord passthrough lanes. For example, make the Discord callback source use the same underlying platform namespace as connector inbound events, or store and compare explicit trusted scope/chat/thread/participant identity fields instead of two differently normalized full session keys. Add a regression test that mints a prompt from a Platform.DISCORD channel session and resolves it through _discord_interaction_to_event/_on_passthrough; retain the non-owner, shared-session, DM, and pending-not-popped cases.

Security evidence:

  • trust boundary: The connector-authenticated relay transport supplies MessageEvent identity fields, but a channel participant controls the button interaction attributed to its own user_id. The pending prompt registry maps an unguessable prompt_id to a privileged session_key and resolver kind. _consume_prompt_response is the authorization boundary because it runs before BasePlatformAdapter.handle_message and therefore before the normal message authorization/session dispatch path. The privileged sinks are resolve_gateway_approval (including permanent approval), slash_confirm.resolve, and resolve_gateway_clarify/mark_awaiting_text.
  • source/sink/invariant: For per-user group/channel sessions, only a callback authenticated as the participant represented by the pending prompt session may reach a resolver; an unauthorized callback must be consumed without popping the prompt. For deliberately shared group/thread sessions, any participant mapped to the same shared session may respond. The PR claims re-deriving build_session_key from the callback yields the same canonical identity as the pending session. That claim fails when the message and callback enter through different relay normalization lanes whose platform components differ.
  • current-main reproduction: The current-main gateway/relay/adapter.py is byte-identical to PR parent 9b909115 for the reviewed target (SHA-256 8c588e5a48c32acada2d12910225c7bf345e893258ffac5a7f17322ab438984b). A focused probe compiled the exact _consume_prompt_response function from current main 339d9686 and created a per-user channel prompt for owner followed by an attacker response. It returned consumed=True, called resolve_gateway_approval with choice always, and removed the pending prompt, reproducing the reported cross-user authorization failure.
  • PR-head or patch-replay validation: The reviewed checkout is exactly 33ce10e43d0a396d23b7b916c18db6f2ccde747c. Its parent 9b909115 is the merge base with current main 339d9686, and both reviewed target files are unchanged between that parent and current main, so the single-commit patch has coherent current-main replay semantics without conflict. On PR head, a matching Platform.RELAY owner resolved and popped the prompt; a matching-platform non-owner was rejected without resolution or pop. A Platform.DISCORD owner prompt answered through the real Discord passthrough normalizer was rejected: pending key agent:main:discord:channel:chan42:owner versus callback key agent:main:relay:channel:chan42:owner.
  • positive/negative cases: Positive cases checked in one pass were that a same-platform rightful owner resolves, shared-session behavior remains allowed by the PR test, and the current-main attacker reproduction reaches the privileged resolver. Negative cases checked were that a same-platform attacker does not resolve or evict the prompt and that a Discord rightful owner through the passthrough callback also does not resolve, exposing the regression. The PR tests additionally cover owner, non-owner, and shared per-user configuration, but synthesize all new channel sources as platform=relay and therefore miss the cross-lane positive case.
  • residual bypass search: Reviewed all pending-prompt producers and resolver branches in gateway/relay/adapter.py, both _on_inbound and _on_passthrough consumption paths, SessionSource reconstruction in gateway/relay/ws_transport.py, Discord interaction normalization, and build_session_key group/thread isolation rules. The check protects exec approval, slash confirm, and clarify uniformly and peeks before pop. Empty session keys, missing sources, and DMs remain fail-open by explicit design; no source-backed channel bypass was found beyond deliberately shared sessions. The material residual issue found is the Discord platform-namespace mismatch, which denies authorized callbacks rather than admitting an attacker.
  • reviewer validation: Independently traced source-to-sink control flow and ran a focused asynchronous probe against exact local commit objects. git diff --check passed, and the relevant current-main/parent files compared equal. The repository pytest suite could not be executed because no checkout/shared virtual environment with pytest was available and /usr/bin/python reported No module named pytest.

Uncertainty: The full focused pytest module and broader gateway suite were not runnable in the available Python environment because pytest was not installed. No live connector/provider end-to-end environment was available; the Discord regression was validated through the actual repository event constructors and resolver path in-process.

Signed: GPT-5.6-sol-xhigh in Codex

The relay adapter resolves an approval / slash-confirm / clarify button
click in _consume_prompt_response, which runs *before* handle_message
(see _on_passthrough). The normal _is_user_authorized gate therefore never
runs for a consumed prompt, and the resolver keys only off the button's
prompt_id -> session_key, never the clicker's identity.

In a per-user channel session (group_sessions_per_user, the default), any
other member of the channel who can see the buttons could click "Always"
and resolve -- and permanently allowlist -- another user's dangerous-command
approval (CWE-639). The native adapters already guard this: qqbot's
_is_authorized_interaction_for_session and whatsapp_cloud's
_is_interactive_sender_authorized require the clicker to own the session,
and NousResearch#41226 fixed the same "any channel member can click Approve" class for
the Slack/Feishu/Discord native adapters -- but the relay passthrough path
was never covered.

Re-derive the caller's session key exactly as handle_message does
(build_session_key + the same config) and require an exact match before
resolving. A shared (non-per-user) session collapses every member's source
to one key so all members stay allowed; a per-user key carries the
participant id so only its owner matches. DMs are 1:1 and fail open. The
check peeks (does not pop) so a rejected stray click cannot evict a
still-pending prompt from under its real owner.
@necoweb3
necoweb3 force-pushed the fix/relay-prompt-owner-scope branch from 33ce10e to 2d0d496 Compare July 26, 2026 19:17
…d session key

Review follow-up: the owner check re-derived `build_session_key` from the
callback's source and required an exact match against the pending key. The two
relay lanes normalize the same conversation differently, so that comparison
rejected the *legitimate* owner of every Discord native prompt button.

Ordinary Discord traffic keeps the underlying platform through
`ws_transport._event_from_wire` (`agent:main:discord:...`), while a component
press travels the passthrough plane, where `_discord_interaction_to_event`
deliberately stamps `Platform.RELAY` — it must, so the key matches the one the
connector bound the follow-up capability under — and carries no `thread_id`
(`agent:main:relay:channel:...`). Probing the shipped code, an owner-authored
"always" click on their own approval was consumed and dropped: pending
`agent:main:discord:channel:chan42:owner` vs callback
`agent:main:relay:channel:chan42:owner`. Threads diverge on two more segments
(`thread:th9:th9` vs `channel:th9`), so normalizing the platform slot alone
would not have been enough. All three prompt kinds were affected.

Bind ownership to the participant id instead — the one identifier both lanes
carry unchanged. `_capture_scope` now records, per session key it sees inbound,
the participant that key isolates (`_remember_session_owner`); a key that does
not end in its sender's own id is a shared group/thread session and is
deliberately left unowned. `_caller_owns_prompt_session` reads that back and
compares ids, the same `operator == session_user` shape the native qqbot
adapter uses — without its session-key parser, which `gateway/run.py`'s
`_parse_session_key` refuses to write because the trailing segment may be a
user id or a thread id.

Fails open where there is no single owner to protect (empty key, shared
session, DM, or a session never seen inbound — a cron- or API-started turn,
unchanged from before the gate existed) and closed on an author-less answer.
The peek-don't-pop behaviour is unchanged.

Tests are rebuilt around the real lanes: sources now come from
`_event_from_wire` (which also fixes the `AttributeError: 'str' object has no
attribute 'value'` the earlier hand-built `platform="relay"` fixtures raised in
`build_session_key`), and two new cases drive a Discord button through
`_on_passthrough`/`_discord_interaction_to_event` against a
`Platform.DISCORD`-keyed session — owner resolves, co-member does not. A third
covers the per-user thread lane, and a fourth pins the unowned-session
fall-open. On the previous head the two cross-lane owner tests and the
fall-open test fail; on `main` the two non-owner tests fail.
@necoweb3
necoweb3 force-pushed the fix/relay-prompt-owner-scope branch from b620b81 to 58d039f Compare July 26, 2026 20:52

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for addressing a real relay authorization boundary: current main consumes Discord prompt buttons before handle_message (gateway/relay/adapter.py:653-659) and resolves pending approvals after an unconditional pop (:1846-1868). The participant-based follow-up correctly avoids the Discord relay/native session-key mismatch described in the earlier review.

Problems

  • gateway/relay/adapter.py:403-411 bounds the owner map by evicting oldest entries even while their prompts remain live. Once evicted, :1443-1445 deliberately fails open, so cache churn can restore cross-user resolution for an unexpired approval.
  • gateway/relay/adapter.py:389-401 uses the raw participant identifier, but current main canonicalizes WhatsApp group participant identities in gateway/session.py:1108-1113. A mapped LID/JID therefore leaves the prompt unowned and reaches the same fail-open branch.

Suggested changes

  • Bind the owner to each pending prompt, or prevent eviction while that prompt is unexpired; add a cache-churn regression.
  • Canonicalize the stored WhatsApp participant consistently with build_session_key, with a mapped-identity group test.

Automated hermes-sweeper review.

Comment thread gateway/relay/adapter.py
# insertion order, so the tail is the most recently seen.
excess = len(self._session_owner_by_key) - _SESSION_OWNER_CACHE_MAX
if excess > 0:
for stale in list(self._session_owner_by_key)[:excess]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This cap can evict an owner while that session still has an unexpired prompt. _caller_owns_prompt_session() then treats the missing entry as unowned and returns True at lines 1443-1445, allowing a co-member to resolve it. Bind the owner to the pending prompt or retain this entry until the prompt expires, and add a cache-churn regression.

Comment thread gateway/relay/adapter.py
"thread_sessions_per_user", False
),
)
if not key.endswith(f":{participant}"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

build_session_key() canonicalizes WhatsApp group participant IDs on current main (gateway/session.py:1108-1113), but participant here is raw. For a mapped LID/JID, this suffix check fails, no owner is stored, and the fail-open branch permits the callback. Canonicalize consistently or avoid raw suffix inference, with a mapped WhatsApp group test.

@teknium1 teknium1 added sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform area/sessions Session lifecycle, resume, persistence, history labels Jul 30, 2026
@egilewski

Copy link
Copy Markdown
Contributor

not enough evidence

A security conclusion cannot be made from a coherent current-main integration. Review setup attempted a run-owned deterministic patch replay of PR head 58d039f onto current GitHub main 36e41c0; it failed with patch_replay_conflict, leaving only the detached PR-head code to inspect. Static review confirms that the PR intends to bind relay prompt responses to the participant recorded from the inbound session, while current main has substantial overlapping changes in gateway/relay/adapter.py and its tests. The submitted branch's stale/conflicted state is informational and was not treated as a standalone blocker; the evidence gap is that no conflict-resolved tree exists on which to validate the code that would merge.

Security evidence:

  • trust boundary: The untrusted source is a relay-delivered prompt response, including a Discord component interaction authored by any member who can see a prompt. The security-sensitive sink is resolution of exec approvals, slash confirmations, and clarifications through the pending-prompt registry. The authorization boundary is between the authenticated participant represented by event.source identifiers and the participant whose per-user session created the pending prompt.
  • source/sink/invariant: For a per-user group, channel, or thread session, only the participant that owns the session may consume its pending prompt or reach the approval, confirmation, or clarification resolver; a rejected caller must not evict the prompt. Shared sessions and one-to-one sessions retain their intended responder semantics. The PR records participant ownership when an inbound session key is minted and checks that owner before _pop_prompt and resolver dispatch.
  • current-main reproduction: No runtime current-main reproduction was completed because the run-owned replay failed and no coherent current-main integration tree was available. Static inspection of current main 36e41c0 showed _consume_prompt_response popping a prompt before resolver dispatch without the PR's participant-owner check, but static inspection is not a positive runtime reproduction.
  • PR-head or patch-replay validation: The run-owned deterministic replay onto current main failed at patch_replay_conflict. The PR-head diff and detached checkout were inspected, but PR-head-only behavior is not evidence for the conflicted result that would merge. A coherent replay with conflicts resolved in gateway/relay/adapter.py and tests/gateway/relay/test_relay_interactive.py is the exact missing setup evidence.
  • positive/negative cases: The added PR-head tests define owner resolution and non-owner rejection with prompt preservation for per-user channels, shared-channel resolution by another member, unowned-session compatibility, Discord cross-lane owner/non-owner behavior, and per-user thread owner resolution. The source contains alternate-participant-ID, absent-author, and bounded owner-cache branches, but the added tests do not cover those branches. These tests were source-reviewed but could not be executed because pytest was unavailable in the review environment.
  • residual bypass search: Static search covered the changed prompt-consumption path, ownership capture, Discord passthrough normalization, session-key construction use, and overlapping current-main edits. Residual bypass analysis remains incomplete because current main substantially modifies the same adapter and test file after the merge base, and the failed replay leaves unknown which ownership capture and consumption ordering would survive conflict resolution.
  • reviewer validation: The checkout identity, merge base, changed paths, PR-head patch, current-main version, and overlapping diff were inspected locally, and gateway/relay/adapter.py compiled successfully; focused tests could not run because no pytest-capable environment was available, so only syntax was validated, not security behavior.

Uncertainty: The conflict-resolved implementation that would actually be proposed for current main is unavailable; runtime positive and negative security cases were not executed because the checkout lacks pytest and a pytest-capable virtual environment; it is unknown whether conflict resolution would preserve current-main relay behavior while applying the ownership capture and pre-consumption authorization check; integration-specific residual bypasses cannot be excluded without a coherent replay and executable focused tests.

Signed: GPT-5.6-sol-xhigh in Codex

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

_remember_session_owner() compares a WhatsApp session key against the raw participant JID/LID, but current build_session_key() canonicalizes that participant before appending it. For a mapped group member the suffix check fails, so no owner is recorded and _caller_owns_prompt_session() takes its missing-owner fail-open path. In an invocation probe, a different group member could still resolve the victim's always exec approval after this mechanism was patch-replayed onto current GitHub main. Please record and compare the canonical participant identity used by the session key (or bind the authenticated owner directly into each prompt), and cover mapped JID/LID owner and non-owner responses.

Security evidence:

  • trust boundary: a non-owner group member's interactive relay response crosses into another member's exec-approval resolver.
  • source/sink/invariant: _remember_session_owner() and _caller_owns_prompt_session() must bind a pending per-user prompt to the same canonical identity that build_session_key() uses before any resolve_gateway_approval() call.
  • current-main reproduction: with a mapped WhatsApp LID, a second group member's always response reached the victim session's resolver and consumed the prompt.
  • PR-head or patch-replay validation: the source-equivalent production mechanism was patch-replayed onto current GitHub main; the canonical key ended in 15551234567, the raw owner was 999999999999999@lid, the owner map remained empty, and the attack still resolved.
  • positive/negative cases: the Discord control stored its raw owner, rejected a different responder, and retained the prompt; the mapped WhatsApp case stored no owner, called the resolver, and removed the prompt.
  • residual bypass search: exec approvals, slash confirmations, and clarifications share this pre-pop ownership guard, so the same missing-owner state reaches all three prompt kinds.
  • reviewer validation: both probes imported gateway.relay.adapter from their intended managed worktrees; the replay passed Python compilation and whitespace validation.

Not checked:

  • Pytest validation
  • CodeRabbit review

Signed: GPT-5.6-sol-xhigh in Codex

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

Labels

area/auth Authentication, OAuth, credential pools area/sessions Session lifecycle, resume, persistence, history comp/gateway Gateway runner, session dispatch, delivery needs-repro Bug needs reproduction steps P2 Medium — degraded but workaround exists platform/discord Discord bot adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants