Repository navigation
set-main lets a member demote another owner's Main Bot and promote their own (owner check covers only body.id) #613
Copy link
Copy link
Closed
Labels
area:securityAuth, grants, approvals, secretsAuth, grants, approvals, secretsbugSomething isn't workingSomething isn't working
Description
Symptom
A signed-in member can change the deployment-wide Main Bot list through set-main, which crosses the owner boundary this endpoint is supposed to enforce:
- demote another owner's Main Bot:
set-main { id: <my Bot>, replace: <their Main Bot> }—authz.jsonly checksbody.id; - promote their own Bot to Main (it appends to
state.mainBotIds), andrt.isMainthen grants that Bot the Main-only tools.
Found while reviewing #612 (owner-scoped access for the Agent endpoints and delete answers). Not introduced by #612 and not part of its scope — filed separately, as the reviewer, no code changed.
Evidence (main 7df78baef, checked on the #612 tree which is identical here)
core/src/host/authz.js:260—if (ep === 'set-main') return requireBot(body?.id): only the target Bot is ownership-checked.core/src/host/channel.js:257-262—body.replaceis forwarded with no check.core/src/host/roster.js:491-511—setMaineditsstate.mainBotIds(one list for the whole deployment) and, whenreplaceis given, replaces that entry withid.replacemust be a current Main Bot (:501-502), which is exactly the other owner's Bot.core/src/host/tools.js—rt.isMain(self)alone gatesstop_bot(:236),create_child_bot/create_bot(:266), changing another Bot (:299), creating (:365) and deleting (:449) group chats. So a Bot promoted by its owner's human passes those checks team-wide.
Reproduction
Two members (the fixture TEAM in core/test/room-membership.test.js already builds Alice/Bob with chief-a / spec-a and chief-b / spec-b):
- Bob signs in (session token), owns
chief-b;state.mainBotIdsis['chief-a']. - Bob calls
set-main { id: 'chief-b', main: true }→ succeeds (requireBot('chief-b')),state.mainBotIdsbecomes['chief-a','chief-b']— Bob's Bot is now a Main Bot, and Bob's Bot is not shared with Alice. - Bob calls
set-main { id: 'chief-b', replace: 'chief-a' }→ succeeds,state.mainBotIdsbecomes['chief-b']: Alice's Main Bot is demoted, her Bot loses the Main-only tools, anddeleteBotfor her Bot is no longer refused as a Main Bot.
Both steps pass today. Neither is what #49 promised ("a different member or their bot cannot access them by changing IDs").
Expected
Pick one (product decision — this is the permissions surface):
- Owner-scoped
replace: inauthz.js, forset-mainwithbody.replace, require that the actor also ownsreplace(requireBot(body.replace)), and inroster.jsrefuse areplacewhose Bot has another owner. Smallest change; also fixes the demote case exactly. - Per-owner Main list:
state.mainBotIdsper member (schema change,store.jsmigration; the rule the file already uses fordesignatedChiefId). Correct long term, much larger. - State the rule: a member's Main Bots are deployment-wide and an ownerless list by design — then
set-mainmust beblocked-sharedwith more than one member, likeadd-agent, until (2).
Recommendation: (1) now, (2) when team mode leaves the disabled state.
Tests to add
authz.test.js: with two members,set-main { id: ownBot, replace: otherOwnerBot }isbot/forbidden;set-main { id: ownBot, main: true }while another owner has a Main is refused (or becomesbot/blocked-shared, per the chosen option).roster.test.js:setMainwith areplaceBot of a different owner throws and leavesstate.mainBotIdsunchanged.
Notes
#612PASSed without this; keep it out of that PR so the two changes stay reviewable.- Related:
#49(owner-scoped access, closed),#50(rooms),#51(cross-owner routes). This is the roster-administration half of#49that the endpoint table missed.
Metadata
Metadata
Assignees
Labels
area:securityAuth, grants, approvals, secretsAuth, grants, approvals, secretsbugSomething isn't workingSomething isn't working
Activity
Birfy commentedon Oct 21, 2026
Triage (2026-10-21):
needs-human→agent-ready. Keptbug+area:security, and it now heads the Dev queue: #613 → #609 → #608.Why it moves ahead of #609/#608:
set-mainvalidates onlybody.idbut forwardsbody.replace, so a member can demote another owner's Main Bot and promote their own — andrt.isMainalone gatesstop_bot,create_bot/create_child_bot, changing another Bot, and creating/deleting group chats. That is privilege escalation on the permissions surface, not a cosmetic gap.The product decision the issue left open is settled: option (1) — the narrowest fix.
body.idand onbody.replace(requireBot(body.replace)), plus a refusal insetMainwhen thereplaceBot belongs to another owner;replacefails and leavesstate.mainBotIdsunchanged.Out of scope: option (2), the per-owner
mainBotIdslist with astore.jsmigration — that is a later change once team mode leaves the disabled state, and it is not needed to close the escalation. Keep this out of #612: #612 is PASSed on its own, and the two changes should stay reviewable.Gate as usual: tip PASS + CI all green + reverting the source change turns the new tests red.
risk:T*is controller-only — none written by hand or transcribed; if absent, the actual tier is in therisk tier (report only)job summary and it does not block.Birfy commentedon Oct 21, 2026
Claiming. Branch
fix/613-set-main-owner, forked frommain81c708d.Fix, narrowest of the three options in the issue (option 1):
core/src/host/authz.js—set-mainnow also requires the actor to ownbody.replacewhen it names a different Bot, so taking another owner's Main Bot's place isbot/forbiddenbefore it reaches the roster.core/src/host/roster.js—setMainrefuses areplacewhose Bot has another owner and leavesstate.mainBotIdsunchanged, so the invariant holds for every caller, not only the endpoint.Order: the failing tests first, then the fix. Evidence goes in the PR.
Not changing the deployment-wide shape of
state.mainBotIds(option 2) and not makingset-mainblocked-shared(option 3): both are the team-mode decision the issue leaves for later, and the plain promotion of a Bot the caller owns stays as it is.fix(set-main): scope replace to the caller’s own Main Bot (ThinkFlowL…
Restore the ThinkFlowLab#574 and ThinkFlowLab#613 paragraphs the Thin…
Restore the ThinkFlowLab#608 'no verdict at all' paragraph the ThinkF…