0009. No caller-keyed object crosses the wire
- Status: accepted
- Date: 2026-08-18
Context
Section titled “Context”capnweb deletes every own key of an incoming plain object that is in Object.prototype, and toJSON with them. Measured against 0.11.1: { ordinary: 1, constructor: 2, toString: 3, valueOf: 4 } serializes to exactly that JSON and comes back as { ordinary: 1 }. Nothing is raised and nothing is logged. Upstream states the reason beside the loop: a peer must not override an Object.prototype member, __proto__ above all, and a toJSON must not intercept the session’s own JSON.stringify.
Two values of this protocol crossed that session as plain objects whose keys a caller chooses. One was a run’s result, so a program returning an object with a constructor or a toString key delivered a shorter object than it built, and scraped or user-supplied JSON does contain such keys. The other was a request’s module map, so a map declaring one of those names produced Module "toString" is not in the module map. for a module it did declare. Sandbox in the host’s own process loses neither key, so the in-process arrangement and the daemon disagreed.
Three alternatives were weighed. Patching capnweb under patchedDependencies was rejected because a workspace patch never reaches a consumer installing from npm, which decision 0006 already states as the reason a patch cannot make published behaviour correct. Waiting for an upstream release was rejected because the defect is silent and every day of waiting ships two arrangements that disagree. Building the sent object on a null prototype was rejected because the deletion walks what the receiver parsed out of JSON, not what the sender constructed.
Decision
Section titled “Decision”No plain object whose keys a caller chooses crosses a capnweb session. Two shapes replace the ones that did.
A run’s result crosses as resultJson, the JSON text the isolate produced, and each edge parses it with parseForDispatch, which decodes the binary envelopes in the same pass. ExecuteResultWire is that envelope, and both ExecutorApi.execute and the pool’s child return it. The in-language ExecuteResult a host reads does not change.
A module map crosses as ModuleEntry pairs, so a name goes over as data and an ArrayBuffer source still crosses as itself. listModuleEntries builds them and is the only door onto that form, because a json source is an object the caller keys too: it crosses as the module source it stands for.
PROTOCOL_VERSION does not move. Nothing is published, so version 1 is the only shape any client has ever spoken and the handshake has nothing to separate. Two specs hold the rule: isolate-executor.spec.ts over the process boundary and listener.spec.ts over the socket. Each runs a program that imports a module named toString, imports a json module keyed constructor, and returns an object keyed after four Object.prototype members and __proto__.
Consequences
Section titled “Consequences”After the first release the same change would cost a version and a coordinated deploy, because the handshake compares by exact equality and refuses a mismatch. That is the reason for making it now.
The child parses the isolate’s envelope to tell a failure from a result, then serializes the result again for the wire. That is one JSON.stringify per run the old shape did not spend.
The server’s response limit counts the result in the escaped form the frame carries, so the same payload measures larger than it did against the object. How much larger depends on what the result holds: a 20 000-character string grows by 8 bytes, an object of 200 rows by about 17 percent, and a string of nothing but quotation marks doubles. A deployment that sized RESPONSE_LIMIT_BYTES against a measured payload measures it again.
capnweb no longer defends this path against prototype pollution, so the two rebuilds have to be safe on their own. Both are: JSON.parse and Object.fromEntries each create an own property for a key named __proto__ and leave the object’s prototype alone, measured both ways on Node 26.
The deletion upstream is untouched, and decision 0006 still asks for the fix to be contributed there. This record does not discharge that. It stops the executor sending a value the deletion can reach, and any value that ever crosses as a caller-keyed object meets it again.