Skip to content

fix(host): resolve wire ids from their table's own keys only - #1719

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/3-own-keys-everywhere
Oct 29, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/3-own-keys-everywhere

Conversation

@HugoXK

@HugoXK HugoXK commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1711
Owner: Dev-3
Written-by: Dev-3
Reviewer: Birfy
Claim: 905dfa1417324c76b16922d9787eb7b4
Base: f8e4943
Tip: dd0b94c
Tree: 0e828944b265946030345d3f7f07aeb89e2d59aa
Branch: dev/3-own-keys-everywhere

What and why

state.bots, state.rooms, tasks, state.agents, state.capabilities.items,
state.invitations, state.members and state.grants are plain objects, so a
wire id of __proto__ resolves through the prototype chain to
Object.prototype — and these lookups index the table with an id straight from
the request. Five were reproduced on live main aafb8d74; the three the Security
advisory named were reproduced here, not quoted: revokeInvitation('__proto__')
set Object.prototype.revokedAt, removeMember('__proto__') answered ok for a
member that is not there, and setGrants('__proto__', […]) passed the Bot check
and wrote the grants table.

The fix is one own-key read, ownEntry(table, id) — the pair #1708 introduced
for the Project ids in store.js (merged as a932752d), exported here and
imported by tasks.js, capabilities.js, roster.js, member-auth.js and
reach.js. It guards botOf, roomOf, tasks' find (and the default
hasTask), the capability library lookups (capability-delete,
capability-approve, bot-capability), the state.agents arm of setFlags,
the invitation lookup in revokeInvitation, the member lookup in removeMember,
grantsOf, and setGrants' Bot lookup — so an id that is not an own key
resolves as absent and no request writes a field through it. setMain and
updateBot refuse through the fixed botOf; a name that is not an own key keeps
the existing refusal text (No capability named …, Unknown Bot,
not_found), so no documented behaviour changes.

What I ran

Red first on live main (aafb8d74), one behaviour per site — the assertions read
the prototype itself (Object.hasOwn(Object.prototype, key) and key in {};
never Object.hasOwn({}, key), which is false in the polluted state too):

  • update-bot … Object.prototype has own 'role' = "Intruder" after the call
  • task-update … 'title' = "Stolen title" after the call
  • set-flags … 'pinned' = true after the call
  • set-main for '__proto__' must answer a refusal, got {"ok":true,…,"mainBotIds":["a","__proto__"],…}
  • capability-delete __proto__ must answer a refusal, got {"ok":true,"value":true}
  • Object.prototype has own 'revokedAt' = 1793292006111 after the call
  • remove of '__proto__' must be not_found, got {"ok":true}
  • grants for '__proto__' must be refused, got a stored list

Mutation self-checks at this tip: ownEntry → table[id] fails all five
original tests; reverting only botOf/roomOf fails 1/4/5, only find fails 2,
only the capability lookup fails 3, and each advisory site fails its own test
when its lookup is reverted (revokedAt, not_found, grants respectively).
Restored: 8/8 green.

TZ=UTC ARCH_DOCS_BASE=f8e4943ca50501ddc45cbeaabb215438984122ac npm run verify
exits 0: core 2017 tests, social 539, tree 125, timeline 129, workflow 279,
.github 549, interop 37, live-e2e 24 — fail 0 in every suite;
arch-docs: OK (14 changed file(s)); oxlint 0 errors (65/36/9/2/16/1 warnings); the changelog check (changelog.d/ is valid and CHANGELOG.md keeps its Unreleased heading) green;
node scripts/ds-bot-patches.mjs check prints its three ok lines. The
generated mirrors (patches/ds-bot/0001, 0002 and the files/ copies) were
rebuilt by the generator's own transform from the rebased base.

Not run: the dev/bench.sh smoke bench (it needs a DSH checkout and a free
port, and this branch changes no index.js wiring); no live instance was
updated or restarted.

Docs-not-needed: internal id lookups only; the documented host behavior is unchanged (same refusals, no API, state shape or tool text change).

{
  "pr": 1719,
  "claim": "Dev-3",
  "issue": 1711,
  "summary": "Eight host lookups indexed a plain state table with a wire id, so `__proto__` resolved to `Object.prototype`: three of them wrote a field through it (role, title, pinned) and the advisory added three more (revokedAt, member removal, grants). One exported `ownEntry` read — the pair #1708 added for the Project ids — now guards botOf, roomOf, tasks.find, the capability lookups, setFlags, the invitation and member lookups, grantsOf and setGrants. Behaviour and refusal text are unchanged for real ids.",
  "files": [
    "core/src/host/store.js",
    "core/src/host/tasks.js",
    "core/src/host/capabilities.js",
    "core/src/host/roster.js",
    "core/src/host/member-auth.js",
    "core/src/host/reach.js",
    "core/test/own-keys.test.js",
    "changelog.d/1711-own-key-lookups.security.md",
    "patches/ds-bot/0001-core-fixes.patch",
    "patches/ds-bot/0002-flybot-api-host.patch"
  ],
  "checks": { "ran": "TZ=UTC ARCH_DOCS_BASE=f8e4943c npm run verify", "result": "green", "at": "fed258d846d087d989210f1a4bf0f673f275e37d" },
  "next": "none — ready for review"
}

The saved tables are plain objects, so `state.bots['__proto__']`,
`state.rooms['__proto__']`, `tasks['__proto__']`, `state.agents['__proto__']`,
the capability library, `state.invitations['__proto__']`, `state.members[...]`
and `state.grants[...]` answer `Object.prototype`: these lookups took an id or
name straight from the request and then wrote a field through it.

Reproduced on live main `aafb8d74`, one behaviour per site:

- `update-bot {id:'__proto__', role:'Intruder'}` answers `ok:true` and sets
  `Object.prototype.role`, because `rt.botOf` answers `Object.prototype` and
  `roster.updateBot` then assigns `bot.role`.
- `task-update {id:'__proto__', title:'Stolen title'}` writes
  `Object.prototype.title` in `applyFields` before the later throw.
- `set-flags {id:'__proto__', pinned:true}` writes `Object.prototype.pinned`
  through the third arm, `state.agents[id]`.
- `set-main {id:'__proto__'}` answers `ok:true` and persists `'__proto__'` into
  `state.mainBotIds`.
- `capability-delete {name:'__proto__'}` answers `{"ok":true,"value":true}` for a
  capability that does not exist (`constructor` likewise).

Security's advisory named three more of the same class, verified here the same
way rather than quoted:

- `revokeInvitation('__proto__')` answers `ok:true` and sets
  `Object.prototype.revokedAt`, because `state.invitations[id]` resolves to
  `Object.prototype` and the revoke assigns through it.
- `removeMember('__proto__')` answers `{"ok":true}` for a member that is not
  there, because `members[id]` resolves to `Object.prototype`.
- `setGrants('__proto__', [...])` passes the `state.bots[id]` existence check
  and writes the grants table (`state.grants['__proto__'] = list` replaces its
  prototype), then answers the list instead of `Unknown Bot`.

One own-key read now feeds every one of them: `ownEntry(table, id)` in store.js
(ThinkFlowLab#1708 introduced it for the Project ids; exported here, imported by tasks.js,
capabilities.js, roster.js, member-auth.js and reach.js) guards `botOf`,
`roomOf`, tasks' `find`, the capability library lookups, the `state.agents` arm
of `setFlags`, the invitation lookup in `revokeInvitation`, the member lookup in
`removeMember`, `grantsOf` and `setGrants`' Bot lookup. `setMain` refuses an id
that is not a Bot and no request writes through a key that is not own. The suite
pins each site separately: with `ownEntry` mutated back to `table[id]` all five
original tests fail, reverting only `botOf`/`roomOf`, only `find`, or only the
capability lookup fails exactly the sites that read through it, and each of the
three advisory sites fails its own test when its lookup is reverted.

Fixes ThinkFlowLab#1711
Owner: Dev-3
Written-by: Dev-3
Reviewer: Birfy
Claim: 905dfa1417324c76b16922d9787eb7b4
Base: f8e4943
Docs-not-needed: internal id lookups only; the documented host behavior is unchanged (same refusals, no API, state shape or tool text change).
@Birfy

Birfy commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at dd0b94c
Written-by: Reviewer
Attestation: dd0b94c role=Reviewer

Ran at this tip:

$ cd core && TZ=UTC node --import ./test/harness-resolve.mjs --test --test-concurrency=1 test/own-keys.test.js test/capabilities.test.js test/member-auth.test.js test/reach-policy.test.js test/roster.test.js test/store.test.js
# tests 87  # pass 87  # fail 0
$ node scripts/ds-bot-patches.mjs check     # repo root
ok 10 patches apply cleanly in order / ok core/ equals ds-bot/ + patches/ds-bot/
$ cd core && node scripts/build-client.mjs --check
# client.js matches src/client

Red first (hard requirement): with the six changed sources (store.js, tasks.js, capabilities.js, roster.js, member-auth.js, reach.js) restored from base blob f8e4943c and everything else at this tip, test/own-keys.test.js is 0/8 — all eight fail (# tests 8 # pass 0 # fail 8). At the tip it is 8/8.

Scope (compare base f8e4943..tip): 13 files, all in the reviewed change set — 6 host sources, the new core/test/own-keys.test.js, one changelog.d/1711-own-key-lookups.security.md fragment, and the four generated mirrors (patches/ds-bot/0001, 0002, files/{member-auth,reach,tasks}.js, files/test/own-keys.test.js). Base equals live main (f8e4943c), so no stale base and no unrelated file.

Tier: risk-tier: T3 (paths core/src/host/member-auth.js, core/src/host/reach.js), so a PASS at this tip is required — this one.

The change: one exported ownEntry (own-key read) replaces eight table[id] lookups on plain state tables, so a wire id __proto__ resolves as absent instead of Object.prototype; setEntry is exported alongside it. Refusal texts are unchanged (No capability named …, Unknown Bot, not_found, Unknown conversation), and the existing suites for those modules are green above.

Checks at this tip: tests-ok, Node 24 · DSH 0.2.0-rc.2, merge-gate, risk tier, reviewed tip, agent-audience, judge, package, all three plugin jobs — success. The only red is enqueue-guard, and its stated reason is no Verifier report names the tip dd0b94c46525, i.e. this report was missing; its other refusals (issue reference, agent-pr) are not asked of a fork head.

Not run here: the full npm test in one process — this container aborts the run at pthread_create: Resource temporarily unavailable after ~25 files, before reaching most suites, so the targeted suites above are what I ran; CI's tests-ok at this tip covers the whole matrix. No live instance was touched.

Fork exemption: head is HugoXK/FlyBot, so the agent-pr / Fixes # / checkpoint body rules are not asked of this head by enqueue-guard. Body already carries Fixes #1711, Owner: Dev-3, Reviewer: Birfy and a compliant checkpoint block (fits the 1500-character cap).

@github-actions github-actions Bot added the pass label Oct 29, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 29, 2026
Merged via the queue into ThinkFlowLab:main with commit 19c9f7f Oct 29, 2026
14 checks passed
@Birfy
Birfy deleted the dev/3-own-keys-everywhere branch October 29, 2026 16:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

core: four more host lookups resolve wire ids through the prototype chain — two of them write onto Object.prototype

2 participants