Skip to content

tenancy(chat): LLC agent (CEO) conversations mix into ordinary /api/chat/sessions with no company scoping — multiple companies' chats would pile into one flat list #12685

Closed

Description

@mrveiss

Summary

LLC agent (CEO) heartbeat conversations are registered in the same chat-session store as ordinary user chats, and are returned by the ordinary GET /api/chat/sessions list with no company scoping. With more than one company, every company's CEO/agent chats pile into one flat, shared list — a cross-tenant mixing of conversations.

Observed (live)

GET /api/chat/sessions   (ordinary user chat list)
-> { "sessions": [
     { "id": "969c0409-12cb-4e9f-bb88-c596207d…",
       "title": "CEO · 22d907a9-31fd-4bb4-8b42-89…" }   ← an LLC agent's conversation
   ] }

The session titled CEO · <company_id> is an LLC agent heartbeat conversation appearing in the general chat list. Nothing in the response ties it to (or filters it by) a company/org — the company id is only embedded in a display title, not a scoping field.

Why this is a tenancy problem

  • Cross-company mixing. The list is not filtered by company_id/org_id. Two companies' CEO agents both surface here; a user viewing "their chats" sees agent conversations from every company on the instance.
  • User vs agent conflation. An operator's own chats and autonomous agent runs share one namespace, so agent traffic clutters (and is indistinguishable in-store from) human sessions.
  • Scales the wrong way. The behaviour is invisible with one company (the current test state) and becomes a leak the moment a second company exists — exactly the multi-tenant case the LLC/company-OS product is for.

Likely cause

LLC agent runs create/record their conversation through the shared chat-session path rather than a company-scoped LLC conversation store, and the chat-session list query has no company/agent filter. The company identity is carried as a title string ("CEO · {company_id}") instead of a first-class scoping column, so it cannot be filtered on.

Recommendation

  1. Scope chat sessions by tenant. Add/enforce company_id (and agent_id / origin=user|agent) on chat sessions, and filter GET /api/chat/sessions to the caller's user + org context — the same tenant discipline the LLC company routers already apply (require_org_context).
  2. Separate agent conversations from human chats, or at minimum tag and filter them, so the general chat list shows the user's own sessions by default and agent/company conversations are viewed under their company.
  3. Add a test: with two companies each having an agent conversation, GET /api/chat/sessions for a user must not return the other company's agent sessions.

Impact

High for the multi-company product — conversations from different tenants share one unscoped list. No data corruption, but it is a confidentiality/isolation break the moment there is more than one company.

Environment

install.sh deploy at /opt/autobot. Verified live: CEO agent session for company 22d907a9-… present in GET /api/chat/sessions. Related: #12371 (chat endpoint duplication), #12215 (LLC tenant-context discipline).

Activity

  1. mrveiss commented on Aug 22, 2026

    @mrveiss
    OwnerAuthor

    Partly delivered — #14757 merged, issue stays open

    Merged as 060b9e964 on Dev_new_gui. Verified on base, not from the PR:

    • autobot-backend/api/chat_sessions.py:424 — the default listing filters agent-scoped sessions
    • autobot-backend/api/chat_sessions.py:445 — _is_agent_scoped_session present
    • autobot-backend/api/chat_sessions_tenancy_scope_test.py — route-level regression test present
    • Required checks: 10/10 passed, 59 green, 0 failing at merge

    companyId / sessionKind are now first-class from creation through storage to
    read-back, and the Redis-exception branch of _filter_user_sessions fails closed
    instead of returning the unfiltered list.

    Why this does not close the issue

    A review pass raised two boundaries this does not cover, now recorded in the
    PR body:

    1. Single-session-by-id access is not governed by this filter at all. Get,
      update, delete, export, share and the activities routes rely solely on
      validate_session_ownership, which is untouched here and defaults to
      disabled in the three cases tracked by security(chat): ownership enforcement fails OPEN — an unavailable feature-flag service disables every check platform-wide #14010. The list-view symptom is
      closed; the by-id path is not.
    2. An empty ownership set still widens the general list. _filter_user_sessions
      returns the company-filtered but otherwise unfiltered list when the requester's
      own ownership-id set is empty (new account, TTL lapse, or an install predating
      ownership tracking). That branch is security(chat): session ownership filter fails OPEN — a Redis error returns every session on the instance #12867 path (a) — an open product decision,
      not something this change should have silently picked.

    Also still open: sessions created before this change carry no scoping fields and
    remain in the general list. The fix deliberately refuses to match on title, so
    there is no silent re-scoping and no data loss. #14756 covers the backfill as an
    explicit dry-run-first operation.

    Keeping this open until the by-id boundary is decided, since closing it now would
    read as "tenancy is enforced" when only the listing path is.

  2. mrveiss commented on Aug 23, 2026

    @mrveiss
    OwnerAuthor

    Status update: one of the three open boundaries is now closed

    The previous comment listed three things keeping this open. Updating one:

    Legacy sessions — delivered. #14756 is merged (#14838, 136642ce4) and
    closed. chat_history/session_scope_backfill.py is an operator-run, dry-run-by-
    default backfill that tags a pre-existing session only when the id parsed from
    its "CEO · <company_id>" title resolves to a company that actually exists.
    Verified in base by blob hash.

    The refusal to match on title alone is preserved and is the point: a user can
    name an ordinary chat "CEO · <a well-formed uuid that is not a company>", and
    that string is byte-identical in shape to what the real producer writes. Only
    the company-existence lookup separates them. Mutation-checked:

    FIXED (company lookup present)     taggable=[]           user_chat_hidden=no
    MUTANT (title trusted blindly)     taggable=['s-fake']   user_chat_hidden=YES -- data loss
    

    Note this ships the operation; it does not perform it. Running it against a
    live install is a separate, deliberate operator action — --apply after reading
    a dry run. So legacy sessions still appear in the general list until someone
    runs it.

    Still open, unchanged

    1. By-id access is not governed by this filter. Get, update, delete, export,
      share and the activities routes rely solely on validate_session_ownership,
      which defaults to disabled in the three cases tracked by security(chat): ownership enforcement fails OPEN — an unavailable feature-flag service disables every check platform-wide #14010 — still
      open. This is the substantive remaining gap, and it is a security one: it
      fails open.
    2. Empty ownership set widens the general list. Recorded as security(chat): session ownership filter fails OPEN — a Redis error returns every session on the instance #12867 path (a).
      security(chat): session ownership filter fails OPEN — a Redis error returns every session on the instance #12867 itself is now closed; worth confirming whether that closure resolved
      this branch or only the Redis-error branch it was filed for, before assuming
      it is handled.

    Keeping this open. Closing it now would still read as "tenancy is enforced" when
    only the listing path is — and #14010 is the reason, not the backfill.

  3. mrveiss commented on Aug 23, 2026

    @mrveiss
    OwnerAuthor

    Boundary 2 checked, not assumed — #12867's closure does not cover it

    I flagged that #12867 being closed might have resolved the "empty ownership set
    widens the general list" branch. It did not. Verified in base:

    So the two are different branches of the same function, and only one is closed.
    The reasoning for keeping the second is sound — failing closed there would hide
    a user's own history on an install that predates ownership tracking — which is
    precisely why it is a decision rather than a fix.

    Where this issue now stands

    boundary status
    listing path filters agent sessions done (#14757)
    legacy sessions carry no scoping done — backfill shipped (#14756 / #14838), operator-run
    ownership lookup failure widens the list done (#12867)
    empty ownership set widens the list open — needs the pre-ownership-install decision
    by-id paths ungoverned open — #14010

    On #14010 I have opened #14865, which covers its step 1: an unreadable
    enforcement mode no longer reads as "authorization is off". That does not close
    #14010 either — its step 2 (choosing the default posture when the flag is unset)
    flips requests that succeed today and needs its own rollout.

  4. modified the milestones: Backlog, v0.13.0 on Sep 12, 2026
  5. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    Delivered — closing on code evidence from origin/main

    Checked against merged code rather than against the three PRs that reference this issue, because a
    reference is not a delivery. Each of the three recommendations, with the code that implements it:

    1. Scope chat sessions by tenant. companyId and sessionKind are first-class metadata written
    at session creation, not parsed from a title:

    • autobot-backend/api/chat_sessions.py:450 — _is_agent_scoped_session, which reads those two
      fields and explicitly refuses title-matching: "title-matching is the bug this closes, not the fix
      for it"
      .
    • chat_sessions.py:429 — the ordinary list excludes agent-scoped sessions before anything else.
    • chat_sessions.py:432-434 — _filter_user_sessions then narrows to the authenticated user.
    • autobot-backend/tests/test_chat_session_ownership_failclosed.py — a failed ownership lookup
      returns nothing rather than an unfiltered list. That one matters most: without it, the scoping
      above degrades to "no filter" precisely when the lookup breaks.

    2. Separate agent conversations from human chats. Same mechanism — agent conversations are
    tagged and excluded from the default list. The migration decision is recorded in the helper's
    docstring and is the conservative one: sessions predating the fix are not retroactively
    reclassified, because the only signal available is the title, and inferring from it is the original
    defect. They keep appearing exactly as before — no silent re-scoping, no data loss.

    3. The two-company test exists, asserting the exact scenario this issue asked for:

    • autobot-backend/api/chat_sessions_tenancy_scope_test.py:95 —
      test_agent_scoped_session_absent_regardless_of_which_company
    • :86 test_ceo_chat_session_is_excluded
    • :114 test_legacy_session_without_scoping_fields_still_appears — the migration promise, asserted
      rather than described.

    Beyond what was asked

    The legacy gap left open by the migration decision has since been given a tool:
    autobot-backend/chat_history/session_scope_backfill.py, with session_scope_backfill_test.py
    covering the properties that make it safe to run — an ambiguous session is reported and never
    guessed (TestAnAmbiguousSessionIsReportedNeverGuessed), re-running changes nothing
    (TestReRunningChangesNothing), and only the scoping fields are written
    (TestApplyWritesOnlyTheScopingFields).

    It is a __main__ script that renders a plan and applies only with an explicit flag — deliberately
    operator-invoked rather than automatic, which is the right shape for a change to stored data.

    One thing I am not claiming

    I have not verified this on a live multi-company deployment; the evidence above is the merged code
    and its tests. The issue's original observation was made against a running instance, so if a
    host-level confirmation is wanted before this is considered settled, that is a separate check and I
    did not perform it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions