Skip to content

SECURITY: Python session scoping — owner-check only owned sessions (Option B) - #313

Merged
brentrager merged 1 commit into
mainfrom
th-909995-py-optionb
Jul 20, 2026
Merged

SECURITY: Python session scoping — owner-check only owned sessions (Option B)#313
brentrager merged 1 commit into
mainfrom
th-909995-py-optionb

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Fixes the P0 lockout introduced with the Python half of the conversation-scoping fix (#295/#304, th-8fe998). Pearl th-909995.

The bug

_visible_session denied whenever the principal had no email claim. On an auth-enabled server that means an anonymous connection, or a token carrying sub/org/role but no email, is denied everything — including the session it just created (which is ownerless by construction): empty list_conversations, resume mints a fresh conversation, get_session / get_conversation_messages / send_message all answer SESSION_NOT_FOUND. That is "cannot use the product" for anonymous/public-agent chat.

The identical rule in .NET hung CI on Acl_PrivateDoc_OnlyReachesEntitledUser_OverWebSocket (emailless token + anonymous second connection) and was reverted in #309. Neither Python nor Rust had an equivalent test, which is why it went unnoticed here.

The fix — Option B

A session that has an owner is owner-checked (case/whitespace-insensitive email match). A session with no owner — anonymous, emailless, or predating ownership — is reachable, exactly as before scoping shipped. An emailless scope matches no non-empty owner, so the reported P0 stays closed.

Option A (email ?? sub) was rejected: Go's anonymous principal uses the literal sub "anonymous" for every visitor, so keying on sub pools all anonymous visitors and leaks their chats to each other.

SessionStore.create_session / list_conversations gain a keyword-only enforced: bool = False — the store previously had no way to tell "auth disabled, unscoped" from "authenticated but emailless" (both present as a None owner). Without it the resume path would have let an emailless principal bind to anyone's conversation.

Kept

  • _visible_session remains the ONLY sessionId lookup, and test_visible_session_is_the_only_lookup (exactly one direct self._store.get_session( call site) still guards it.
  • Not-yours stays byte-identical to never-existed — no existence oracle.
  • Auth-disabled single-tenant flavor unchanged.

Tests

New/changed in test_conversation_scoping.py:

  • test_emailless_principal_can_use_its_own_session (anonymous-on-auth-enabled and emailless-authenticated): create → get_sessionsend_message (reaches LLM_UNAVAILABLE, i.e. past the ACL gate) → list_conversations → resume by conversationId.
  • test_emailless_principal_still_cannot_reach_an_owned_session — the permissive half is only the ownerless case.
  • test_send_message_never_appends_to_another_users_session — the reported P0, asserted on B's log, not just the error code.
  • test_unowned_conversation_is_reachable_by_every_principal (replaces the old "invisible to every principal").
  • test_store_enforced_scope_admits_ownerless_only_alongside_the_owner — store-level enforced matrix.

Verified the new cases FAIL against the old predicate: restoring it locally fails 4 of them (test_emailless_principal_can_use_its_own_session[emailless-principal], [anonymous-enforced], test_unowned_conversation_is_reachable_by_every_principal, test_store_enforced_scope_admits_ownerless_only_alongside_the_owner) while the P0 guards keep passing.

Gates: uv run pytest 207 passed (python/server) + 52 passed 1 skipped (python); ruff check and ruff format --check clean in both.

🤖 Generated with Claude Code

https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F

…Option B)

The th-8fe998 rule fail-closed on any principal with no email claim, which on an
auth-enabled server denied anonymous connections and emailless authenticated
principals access to the sessions they had just created themselves. The same rule
in .NET hung CI and was reverted in #309.

Option B: an OWNED session is still owner-checked; an OWNERLESS one (anonymous /
emailless / legacy) is reachable as before. An emailless scope matches no non-empty
owner, so authenticated A still cannot read, resume, or send into authenticated B's
owned session, and a refusal appends nothing to B's log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F
@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b6eed9d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@smooai/smooth-operator Patch
@smooai/smooth-operator-web-chat-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@brentrager
brentrager merged commit 5d0a499 into main Jul 20, 2026
2 checks passed
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