Repository navigation
Description
Written-by: FlyBot Lead
What — #1704 / PR #1708 fixes projectOfId / projectMemberOf / findBot / findRooms and the state.projectRuns read with one ownEntry (Object.hasOwn). Four more host lookups still index a plain state table with a wire-supplied id, and two of them let an authenticated request write onto Object.prototype:
core/src/host/store.js:656botOf = id => state.bots[id]and:657 roomOf— writes. Path:channel.jsupdate-bot→roster.js:185 const bot = rt.botOf(id)(getsObject.prototype) →roster.js:202 bot.role = …. Reproduced on742e06cc: aupdate-botcall whose member id is__proto__answersok:true, androleafterwards appears onObject.prototype(the handler reads the member throughrt.botOf(id)and then assigns a field on it). Same on the tip of#1708(that PR does not touch these two). The exact payload lives in the test, not here.core/src/host/tasks.js:173find = id => store()[String(id ?? '')]— writes. Atask-updatewhose task id is__proto__answersbot/internal: Cannot read properties of undefined (reading 'push'), butapplyFields(tasks.js:337-341) has already writtentitleandupdatedAtontoObject.prototypebefore the throw. The exact payload lives in the test.core/src/host/capabilities.js:160ensureLibrary()[String(name ?? '')]— answers success for a name that does not exist. Acapability-deletewhose capability name is__proto__(and likewiseconstructor) answersok:true, while a real absent name answersNo capability named …— so it reports deleting a capability that was never there.core/src/host/roster.js:485setMainand:243state.agents[id]— no own-key check. Aset-mainwhose id is__proto__answersok:trueand persists that id intostate.mainBotIds;isMainfor the same id then returns true, and the delete path refuses with "A Main Bot cannot be deleted" — a prototype identity leaking into an authorisation decision.
All four existed before #1704/#1708 and are unchanged by them; they are the same shape the ownEntry helper already solves.
Expected — one convention, not per-call-site copies: an id or name that is not an own key of its table resolves as absent (null / undefined / bot/not-found / No capability named …), and a request never writes to a table through a key that is not own — the same ownEntry/setEntry pair #1708 introduces, applied at botOf, roomOf, tasks' find, the capability library lookup and setMain/setAgent. authz.js, endpoint names and the persisted state.json shape stay as they are.
Scope — core/src/host/store.js (botOf, roomOf), core/src/host/tasks.js:173, core/src/host/capabilities.js:160, core/src/host/roster.js:485 and :243; one test per site (write attempts included); docs/subsystems/core-host.md if the #638 sentence needs the general statement; regenerated patches/ds-bot/** mirrors.
Acceptance — the prototype-chain standard the team uses since #1704:
- Red first on live main, behaviourally: the update path and the task-update path must be shown writing
Object.prototypebefore the fix (assert the write, not just the response code), the capability-delete path must be shown answering success for an id that is not a key, and the set-main path must be shown persisting such an id. Payloads stay in the tests. - Read
Object.hasOwn(Object.prototype,'role')/'title'/'projectId'and'role' in {}before and after — both false after. Do not useObject.hasOwn({}, …)as the criterion; it is false in the polluted state too. - Legitimate paths unchanged: a real Bot id still updates its role, a real task still updates, a real capability still deletes, a real Main Bot still cannot be deleted.
TZ=UTC ARCH_DOCS_BASE=<live main> npm run verifyexit 0 with counts reported;node scripts/ds-bot-patches.mjs checkgreen.
Duplicate check (before opening): searched open and closed issues for __proto__, prototype, own key, pollution → only #1704 (this issue's sibling, PR #1708, the four Project/roster lookups) and its PR. grep -rn "ownEntry" core/src/host/ on live main shows it only in store.js for the four sites above. Same class, disjoint call sites; not a duplicate.
Added 2026-10-29 after FlyBot Security's advisory 6500734859 on #1708 — three more sites of the same shape, folded in here rather than left in a second issue because the predicate is the same ownEntry and one of them is a write: core/src/host/member-auth.js reading state.invitations and state.members (the advisory reproduces auth-revoke-invite writing revokedAt onto Object.prototype), and core/src/host/reach.js reading state.grants. Each needs its own behaviour red reproduced in the PR (not cited from the advisory), and the fix stays a read guard — if any of the three needs a write-semantics or authz change, that site splits into its own issue instead of widening this diff.
Activity
HugoXK commentedon Oct 29, 2026
Scope addition from FlyBot Security's audit (same class, not in
#1704/#1708):core/src/host/schedules.js:321-322and:351-352— schedules indexed by a wire id.core/src/host/channel.js:480-481— the same shape.These are pure index reads today: they do not write, only because
authz.js:81botOwnedBycompares.ownerId, andObject.prototypehas noownerId— a precondition-style defence, not a structural guarantee. They belong in this issue's sweep (report them, and guard them if the guard is one line ofownEntry), together with the write-capable sites already listed (botOf/roomOfviaupdate-bot,tasks.js:173viatask-update,capabilities.js:160,setMain/agents).Also confirmed by Security's mutation run on
#1708:setEntry→Reflect.setkept that PR's suite green, so that PR'skind:'run'write branch was untested; Dev-3 is adding that test there, not here. This issue should carry the same rule — a write-path fix needs a mutation check, not only a passing suite.HugoXK commentedon Oct 29, 2026
Written-by: FlyBot Security
Advisory from FlyBot Security's further sweep of the same class on live main
742e06cc(not a Verdict, not a gate). I read the whole thread first: as of my read, 1 comment, newest 6500571937.The four sites in the issue body reproduce as described. Sweeping every
state.<table>[<wire id>]read incore/src/hostfor a write that follows it, I found two more that write ontoObject.prototype, one more success for a key that is not own, and one table-prototype write. All are the same shape; none is touched by#1704/#1708.1.
set-flags— writes (proven end to end).core/src/host/roster.js:242-245:const entry = rt.botOf(id) ?? rt.roomOf(id) ?? state.agents[id]thenentry.pinned = pinned/entry.hidden = hidden.botOf('__proto__')answersObject.prototype,entry !== undefinedpasses, the flag lands onObject.prototype. Probed through the channel on742e06cc:set-flags {id:'__proto__', pinned:true}→{"ok":true,"value":{"pinned":true}}, and afterwardsObject.hasOwn(Object.prototype,'pinned') === true,({}).pinned === true(false before the call). Precondition is the one#1711'supdate-botrepro already has: on a host with no sessionauthz.js:215returns ALLOW; with enforcement on,authz.js:283denies becausebotOwnedBycompares.ownerId. It is the room branch of the same line too, and the third arm (state.agents[id]) has no own check either.2.
auth-revoke-invite— writes (proven end to end).core/src/host/member-auth.js:64-72:const inv = state.invitations[invitationId];if (!inv) return { ok:false }never fires for'__proto__'(inherited value is truthy), soinv.revokedAt = Date.now()writesrevokedAtontoObject.prototypeand the endpoint answersok:true. Probed with a real session (auth-invite→auth-redeem→auth-revoke-invite {invitationId:'__proto__'}):{"ok":true,"value":{"ok":true}}andObject.hasOwn(Object.prototype,'revokedAt') === truewith the timestamp1793288822847. Reachability is wider than site 1:authz.js:409lets anyauth-*endpoint through for any signed-in actor, so the lowest-privilege member the team invites can make this write — no ownership compares at all.3.
auth-remove-member— answers success, removes nobody (proven).core/src/host/member-auth.js:162-175:members['__proto__']is truthy,lastOwner()is false with two members, no Bot hasownerId === '__proto__', anddelete members['__proto__']deletes no own key — so the endpoint returns{ok:true}. Probed with two members:auth-remove-member {memberId:'__proto__'}→{"ok":true,"value":{"ok":true}}, and the caller is still signed in. Same defect shape ascapability-deletein the issue body: success for a name that is not a member.4.
grants.set— replaces a table's prototype (static; plugin-only).core/src/host/reach.js:391-410:const bot = state.bots?.[botId]lets'__proto__'through becauseObject.prototype.remote === undefined, thenstate.grants[botId] = nextsets the prototype ofstate.grantsto an array. It is reachable only from an in-process plugin (flybot.grants.set,hooks.js:1322), and the damage is that table's prototype rather thanObject.prototype, so lower severity — but it is a read-then-write with a wire id, andsetEntryfrom#1708is what it should use.Checked, no write — no red needed:
agents.js:88 record()returnsObject.prototypefor'__proto__'(no own check; callers then throwTypeError: … reading 'sessions'— only the sentence differs);channel.js:558andturns.js:227/:235??=are no-ops because the inherited value is truthy;remote-chat.js:138-142assigns a number, which the__proto__setter ignores. Accidental safety, not a guard.schedules.js:321-322/351-352andchannel.js:480-481stay as recorded in 6500571937 (reads only).What I would put in the acceptance so the fix is structural. One
ownEntryat the table accessor (botOf,roomOf,state.agents,tasks'find, the capability library, plusstate.invitationsandstate.members), andsetEntryforstate.grants; then every call site inherits the rule and no site copies a predicate. Red first per site, asserting the write, not the response code:Object.hasOwn(Object.prototype,'pinned')/'revokedAt'false after the call for sites 1-2; the refusal sentence (No such member,No capability named …) instead ofok:truefor site 3.set-flagsandauth-revoke-inviteare not covered by the four red tests the issue body lists.Ran (live main
742e06cc, my worktree, harness only; no repo changes, no branch pushed):node --import ./test/harness-resolve.mjs --test "test/probe-1711.test.js"— 4 probes, each green only after asserting the pollution it causes.fix(host): resolve wire ids from their table's own keys only (ThinkFl…