Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions docs/session-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,18 +269,31 @@ or each get their own private session.
def is_shared_multi_user_session(source, *, group_sessions_per_user, thread_sessions_per_user):
if source.chat_type == "dm":
return False # DMs are always private
if source.thread_id:
return not thread_sessions_per_user # Threads: shared unless per-user
return not group_sessions_per_user # Groups: isolated unless shared
# Mirror build_session_key()'s precedence: start from the group isolation
# setting, then relax it inside a thread unless thread_sessions_per_user is
# set. A thread is isolated only when BOTH flags are True.
isolate_user = group_sessions_per_user
if source.thread_id and not thread_sessions_per_user:
isolate_user = False
return not isolate_user
```

This tracks `build_session_key()`'s group/thread isolation **precedence** — not
its full key string (`gateway/session.py`): the group setting is the base, and a
thread only stays per-user isolated when both `group_sessions_per_user` and
`thread_sessions_per_user` are enabled. The helper deliberately stays
configuration-scoped: when no `user_id_alt`/`user_id` is present,
`build_session_key()` omits the per-user identifier it would otherwise append
and the key is shared by construction, so the helper reports that configuration
decision rather than reconstructing the literal key.

### Summary

| Chat Type | Default | Config Control |
|---|---|---|
| DM | Private (never shared) | N/A |
| Group/Channel | Per-user isolation | `group_sessions_per_user` (default: True) |
| Thread (forum, discord) | Shared (all participants see same context) | `thread_sessions_per_user` (default: False) |
| Thread (forum, discord) | Shared (all participants see same context) | Isolated only when `group_sessions_per_user` **and** `thread_sessions_per_user` (default: False) |

### Impact on System Prompt

Expand Down
12 changes: 8 additions & 4 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -995,15 +995,19 @@ def is_shared_multi_user_session(

Mirrors the isolation rules in :func:`build_session_key`:
- DMs are never shared.
- Threads are shared unless ``thread_sessions_per_user`` is True.
- Non-thread group/channel sessions are shared unless
``group_sessions_per_user`` is True (default: True = isolated).
- Threads follow the same group/thread isolation as
Comment on lines 996 to +1000

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@copilot Thanks — I looked at this closely and am intentionally keeping the helper scoped to the isolation config decision rather than mirroring per-source participant presence, because all three consumers depend on that scoping and widening it would regress two of them:

  1. Prompt contract (build_session_context → session.py): test_non_thread_group_shows_user deliberately pins that a default-isolation group carrying a user_name but no user_id renders **User:** "<name>" (single-user), and its sibling test_shared_non_thread_group_prompt_hides_single_user flips to the multi-user note only when group_sessions_per_user=False. Keying the helper on missing participant-id would silently flip every no-user_id group prompt to multi-user, breaking that intended display (and the sender-prefix branch in run.py that follows it).

  2. Resume IDOR gate (_resume_allowed, slash_commands.py): this is the security-critical one. The gate treats config-isolated sessions as not shared, then applies an explicit fail-closed participant check ("participant id is missing on one side: cannot prove the same owner — fail closed"). If the helper returned shared=True whenever the current source lacks a participant id, a caller with a missing user_id in a config-isolated group would be classified shared and allowed to resume another identified member's per-user session, short-circuiting that fail-closed check. That widens the IDOR surface rather than closing it.

So the helper answers "is participant-sharing enabled by config?" — the question its three call sites actually consume — and the key-shape divergence you spotted for the missing-user_id case is handled downstream (the resume gate's own fail-closed pid check; the prompt's single-user display). Mirroring it into the helper would break the prompt contract and loosen the resume gate, so I'm leaving the helper as-is on this PR.

``build_session_key``: a thread is isolated only when BOTH
``group_sessions_per_user`` and ``thread_sessions_per_user`` are
True; otherwise it is shared.
"""
if source.chat_type == "dm":
return False
if source.thread_id:
return not thread_sessions_per_user
return not group_sessions_per_user
isolate_user = group_sessions_per_user
if source.thread_id and not thread_sessions_per_user:
isolate_user = False
return not isolate_user


def _session_key_namespace(profile: Optional[str]) -> str:
Expand Down
39 changes: 39 additions & 0 deletions tests/gateway/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
build_session_context_prompt,
build_session_key,
canonical_whatsapp_identifier,
is_shared_multi_user_session,
neutralize_untrusted_inline_text,
)

Expand Down Expand Up @@ -728,6 +729,44 @@ def test_discord_thread_sessions_shared_by_default(self):
assert build_session_key(alice) == build_session_key(bob)
assert "alice" not in build_session_key(alice)
assert "bob" not in build_session_key(bob)
def test_shared_helper_matches_build_session_key_group_thread(self):
"""is_shared_multi_user_session must mirror build_session_key's
isolation precedence — not its full key string.

The helper tracks the isolation *decision* (whether build_session_key
would append a per-user participant_id), which stays configuration-
scoped: when no participant ID is present the key is shared by
construction, so the helper deliberately reports shared rather than
reconstructing the literal key. In the group_sessions_per_user=False +
thread_sessions_per_user=True combo, build_session_key emits a SHARED
thread key (no participant_id), so the helper must report the session
shared. Regression: the old thread branch dropped the group factor and
wrongly reported isolated, making the resume IDOR gate deny a
legitimate co-member.
"""
source = SessionSource(
platform=Platform.DISCORD,
chat_id="guild-123",
chat_type="group",
thread_id="thread-9",
user_id="alice",
user_name="Alice",
)
key = build_session_key(
source,
group_sessions_per_user=False,
thread_sessions_per_user=True,
)
shared = is_shared_multi_user_session(
source,
group_sessions_per_user=False,
thread_sessions_per_user=True,
)
# The key carries no participant_id, so the session is genuinely shared.
assert "alice" not in key
assert shared is True
# Tie the helper to the actual key: shared iff participant absent.
assert shared == ("alice" not in key)


class TestSlackWorkspaceSessionKeys:
Expand Down
Loading