Skip to content

SECURITY: Rust conversation scoping — owner-check only owned conversations (Option B) - #318

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

SECURITY: Rust conversation scoping — owner-check only owned conversations (Option B)#318
brentrager merged 1 commit into
mainfrom
th-909995-rust-optionb

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

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_conversation refused everything for UserScope::Denied, which on an auth-enabled server covers both an anonymous connection and an authenticated principal whose token carries sub/org/role but no email. The session such a connection creates is ownerless by construction, so the caller was locked out of its own session: empty list_conversations, resume refused, get_session / get_conversation_messages / send_message all SESSION_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 user participant with a non-blank email — is still owner-checked (case-insensitive, via is_owner). One with no owner is readable, exactly as before scoping shipped. Denied 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.

Kept

  • The UserScope enum (all three variants) and the scoped_session chokepoint at all 7 call sites.
  • Err(_) => false — a storage error remains a denial.
  • Not-yours stays byte-identical to never-existed (existence-oracle tests unchanged and passing).
  • UserScope::Unscoped untouched: the smooth-daemon / LocalServer unauthenticated single-user embedding behaves exactly as before.

One deliberate change beyond the predicate

handle_list_conversations now applies may_read_conversation per conversation instead of the list_conversations_by_org_and_user storage 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 what get_session will hand over. Filtering still happens before the limit, so a page is never silently short. The trait method stays (adapters + their conformance tests); a ponytail: 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/Denied connection that supplies a userEmail in the create frame still mints a conversation owned by that address (UserScope::principal_email() returns None, 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_createdDenied creates a session, lists it, reads its messages, send_message reaches LLM_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_turn and 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_created and an_ownerless_conversation_is_reachable_by_every_scope (22 passed / 2 failed) while every "still closed" guard keeps passing.

Gates: cargo test 604 passed / 3 ignored workspace-wide; cargo clippy --all-targets reports nothing in the touched files (pre-existing pedantic debt elsewhere, th-da8207, untouched); cargo fmt -- --check clean for both touched files (one pre-existing diff in smooth-operator/src/agent_config.rs, present on main and left alone).

🤖 Generated with Claude Code

https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F

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-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d4cc49c

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 799124c into main Jul 20, 2026
1 check 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