You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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
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).
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.
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).
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:
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.
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.
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.
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.
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.
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:
:114test_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.
Activity
security(chat): ownership filter no longer returns every session when…
docs(chat): name the backfill follow-up issue in the scoping docstring (
fix(chat): scope agent sessions out of the general session list (#12685…
mrveiss commentedon Aug 22, 2026
Partly delivered — #14757 merged, issue stays open
Merged as
060b9e964onDev_new_gui. Verified on base, not from the PR:autobot-backend/api/chat_sessions.py:424— the default listing filters agent-scoped sessionsautobot-backend/api/chat_sessions.py:445—_is_agent_scoped_sessionpresentautobot-backend/api/chat_sessions_tenancy_scope_test.py— route-level regression test presentcompanyId/sessionKindare now first-class from creation through storage toread-back, and the Redis-exception branch of
_filter_user_sessionsfails closedinstead 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:
update, delete, export, share and the activities routes rely solely on
validate_session_ownership, which is untouched here and defaults todisabledin 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 isclosed; the by-id path is not.
_filter_user_sessionsreturns 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.
mrveiss commentedon Aug 23, 2026
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) andclosed.
chat_history/session_scope_backfill.pyis 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>", andthat string is byte-identical in shape to what the real producer writes. Only
the company-existence lookup separates them. Mutation-checked:
Note this ships the operation; it does not perform it. Running it against a
live install is a separate, deliberate operator action —
--applyafter readinga dry run. So legacy sessions still appear in the general list until someone
runs it.
Still open, unchanged
share and the activities routes rely solely on
validate_session_ownership,which defaults to
disabledin the three cases tracked by security(chat): ownership enforcement fails OPEN — an unavailable feature-flag service disables every check platform-wide #14010 — stillopen. This is the substantive remaining gap, and it is a security one: it
fails open.
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.
mrveiss commentedon Aug 23, 2026
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:
The Redis-error branch is fixed.
_filter_user_sessionsused toreturn sessions— the unfiltered list — when the ownership lookup failed.It now raises
OwnershipUnavailableError(api/chat_sessions.py:494-501).That is what security(chat): session ownership filter fails OPEN — a Redis error returns every session on the instance #12867 was filed for and what its closure covers.
The empty-set branch is still there, deliberately
(
api/chat_sessions.py:503-506), carrying its own note: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
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.
mrveiss commentedon Sep 28, 2026
Delivered — closing on code evidence from
origin/mainChecked 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.
companyIdandsessionKindare first-class metadata writtenat session creation, not parsed from a title:
autobot-backend/api/chat_sessions.py:450—_is_agent_scoped_session, which reads those twofields 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_sessionsthen narrows to the authenticated user.autobot-backend/tests/test_chat_session_ownership_failclosed.py— a failed ownership lookupreturns 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:86test_ceo_chat_session_is_excluded:114test_legacy_session_without_scoping_fields_still_appears— the migration promise, assertedrather 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, withsession_scope_backfill_test.pycovering 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 — deliberatelyoperator-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.