SECURITY: Rust conversation scoping — owner-check only owned conversations (Option B) - #318
Merged
Merged
Conversation
UserScope::Denied — an anonymous connection to an auth-enabled server, or an authenticated principal with no email claim — was refused every conversation, including the ownerless one it had just created itself. The same rule in .NET hung CI and was reverted in #309. Option B: an OWNED conversation (a user participant with a non-blank email) is still owner-checked; an OWNERLESS one is readable as before scoping shipped. Denied matches no non-empty owner, so authenticated A still cannot read, resume, or send into authenticated B's owned session. Storage errors remain denials. list_conversations applies the same predicate per conversation rather than the by-org-and-user storage pushdown, which can't express "mine or ownerless". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F
🦋 Changeset detectedLatest commit: d4cc49c The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the P0 lockout introduced with the Rust half of the conversation-scoping fix (#302/#306). Pearl th-909995. Sibling of #313 (Python).
The bug
may_read_conversationrefused everything forUserScope::Denied, which on an auth-enabled server covers both an anonymous connection and an authenticated principal whose token carriessub/org/rolebut noemail. The session such a connection creates is ownerless by construction, so the caller was locked out of its own session: emptylist_conversations, resume refused,get_session/get_conversation_messages/send_messageallSESSION_NOT_FOUND.The identical rule in .NET hung CI on
Acl_PrivateDoc_OnlyReachesEntitledUser_OverWebSocket(emailless token + anonymous second connection) and was reverted in #309. Neither Rust nor Python had an equivalent test, which is why it went unnoticed here.The fix — Option B
A conversation that has an owner — a
userparticipant with a non-blank email — is still owner-checked (case-insensitive, viais_owner). One with no owner is readable, exactly as before scoping shipped.Deniedmatches 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 onsubpools all anonymous visitors and leaks their chats to each other.Kept
UserScopeenum (all three variants) and thescoped_sessionchokepoint at all 7 call sites.Err(_) => false— a storage error remains a denial.UserScope::Unscopeduntouched: the smooth-daemon /LocalServerunauthenticated single-user embedding behaves exactly as before.One deliberate change beyond the predicate
handle_list_conversationsnow appliesmay_read_conversationper conversation instead of thelist_conversations_by_org_and_userstorage pushdown, which cannot express "mine or ownerless". Upside: the list and the session reads now share one predicate, so the sidebar can never disagree with whatget_sessionwill hand over. Filtering still happens before the limit, so a page is never silently short. The trait method stays (adapters + their conformance tests); aponytail:comment names the pushdown as the upgrade path if this ever fronts a multi-thousand-conversation org.Known remaining corner (not fixed here, flagging deliberately)
An anonymous/
Deniedconnection that supplies auserEmailin the create frame still mints a conversation owned by that address (UserScope::principal_email()returnsNone, so the frame value is used) and is then denied reading it. That predates this PR and is unchanged by it — the fix would be to stop honoring the frame email whenever auth is configured, which changes the widget OTP-contact path and deserves its own pearl rather than riding along on a security patch.Tests
New in
tests/user_scoping.rs:anonymous_or_emailless_can_use_the_session_it_created—Deniedcreates a session, lists it, reads its messages,send_messagereachesLLM_UNAVAILABLE(i.e. past the ACL gate), and resume binds back to it.an_emailless_scope_still_cannot_reach_an_owned_session— no list entry, no read, no send (one event, no turn spawned, nothing appended to Alice's log), and resume of her conversation mints a fresh one.an_ownerless_conversation_is_reachable_by_every_scope— while Alice's owned conversation stays hers alone.Existing
denied_scope_cannot_read_messages,denied_scope_cannot_send_a_message,auth_enabled_without_a_principal_email_lists_nothing,send_message_into_another_users_session_is_refused_and_runs_no_turnand both existence-oracle tests are unchanged and still pass.Verified the new cases FAIL against the old predicate: restoring it locally fails
anonymous_or_emailless_can_use_the_session_it_createdandan_ownerless_conversation_is_reachable_by_every_scope(22 passed / 2 failed) while every "still closed" guard keeps passing.Gates:
cargo test604 passed / 3 ignored workspace-wide;cargo clippy --all-targetsreports nothing in the touched files (pre-existing pedantic debt elsewhere, th-da8207, untouched);cargo fmt -- --checkclean for both touched files (one pre-existing diff insmooth-operator/src/agent_config.rs, present on main and left alone).🤖 Generated with Claude Code
https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F