Skip to content

th-909995: SECURITY — TS scoping owner-checks only OWNED sessions (Option B) - #310

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

th-909995: SECURITY — TS scoping owner-checks only OWNED sessions (Option B)#310
brentrager merged 1 commit into
mainfrom
th-909995-ts-optionb

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Fixes the lockout half of the per-user scoping rule shipped in #297, without reopening the P0 it closed.

What was broken

scopeEmail() returned an unownable NO_OWNER_SENTINEL when auth was enabled and the principal carried no email, and mayRead(ownerEmail) required ownerEmail === scope. So an authenticated-but-emailless principal — and an anonymous connection to an auth-enabled server — was denied everything: empty list, resume refused, send_message refused. It could not use the session it had just created. That kills anonymous/public-agent chat, a supported Smoo AI scenario.

.NET caught this by hanging CI on an ACL test that authenticates with sub/org/role/groups and no email (reverted in #309). TS had no equivalent test, which is exactly why it shipped here unnoticed.

Option B

A conversation that HAS an owner is owner-checked; a conversation with NO owner (anonymous / emailless / legacy) is allowed, as before.

private mayRead(ownerEmail: string | undefined): boolean {
    const scope = this.scopeEmail();
    if (scope === undefined) return true;   // auth not configured
    if (!ownerEmail) return true;           // no owner to enforce
    return ownerEmail.toLowerCase() === scope.toLowerCase();
}

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.

NO_OWNER_SENTINEL survives and is still load-bearing: it is what makes an emailless scope match no real owner, and it keeps list_conversations empty for emailless principals rather than pooling every anonymous visitor's conversations into one readable bucket. An ownerless conversation is reachable only by holding its (unguessable) id.

Owner comparison is now case-insensitive on both the read path and the list selection, matching .NET and Python — OIDC providers vary on the casing they emit for one identity.

Everything else is unchanged: all four store.getSession( sites stay guarded by mayRead; not-yours and never-existed stay byte-identical; unscoped only with auth disabled; the list filters inside the selection; the principal (never the frame's userEmail) decides the owner.

Tests

New in test/user-scoping.test.ts, for both anonymous-under-auth and emailless-authenticated:

  • can create, read (get_session + get_conversation_messages) and send_message in its own session
  • can resume its own conversation
  • still cannot read or write a session owned by a real email, and nothing is appended to that log
  • owner matching is case-insensitive (read path + list)

All five new tests fail against the old predicate — verified by restoring return scope === undefined || ownerEmail === scope; locally and re-running (5 failed / 16 passed), then restoring Option B.

Preserved from before: the P0 (A → B's owned session ⇒ SESSION_NOT_FOUND, nothing written), the no-existence-oracle byte-identity assertions, empty list for emailless principals, unscoped when auth is disabled.

pnpm test in typescript/server: 216/216 passing. pnpm typecheck in typescript/: clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F

The scoping rule from #297 denied every read when auth was enabled and the
principal carried no email, so an anonymous or emailless caller was locked out
of the session it had just created — empty list, resume refused, send_message
refused. That is "no anonymous/public-agent chat on an auth-enabled server".
.NET caught the same rule by hanging CI (reverted in #309); TS had no test
covering anonymous chat against an auth-enabled server, which is why it shipped.

mayRead now allows an OWNERLESS conversation and owner-checks an owned one. The
reported P0 stays closed — A cannot read or write B's owned session, responses
stay byte-identical to a never-existed id, and an emailless scope still matches
no real owner so the list stays empty rather than pooling anonymous visitors.

Owner comparison is case-insensitive on both the read path and the list
selection, matching .NET/Python (OIDC providers vary on casing).

Tests: anonymous-under-auth and emailless-authenticated can create, read, send
and resume their own session; both are still refused B's owned session; the
case-insensitive match. All five fail against the old predicate (verified by
restoring it locally); 216/216 pass, typecheck clean.

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: 0c8fb3d

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