duplex-rpc
v0.1.1
Published
A small RPC protocol for running untrusted WebAssembly programs behind a narrow, message-based interface. This is an experimental prototype and not intended for production use.
Readme
Sandboxed WebAssembly RPC
A small RPC protocol for running untrusted WebAssembly programs behind a narrow, message-based interface.
⚠️ This is an experimental prototype and not intended for production use. ⚠️
Rationale
Agentic AI is increasingly used for software development, and code review is becoming a bottleneck. In many projects it is no longer practical to manually review every line of implementation code.
Unit tests can help verify that code does what it is supposed to do. However, they are much less effective at proving that code does not do additional unintended things. Such unintended behavior could range from harmless side effects to scanning the filesystem, exfiltrating credentials, or installing malicious software.
This project explores a safer architecture:
- Keep the trusted host small.
- Execute the larger implementation inside a WebAssembly sandbox.
- Treat the WebAssembly binary as untrusted.
- Allow interaction only through a strict, validated RPC protocol.
- The host process decides which capabilities are available.
- The WebAssembly side cannot directly access host resources such as the filesystem, network, environment variables, process execution, or application internals.
This makes it possible to run complex logic inside WebAssembly while keeping external effects under host control.
⚠️ Security Note: The WebAssembly module is sandboxed, but every RPC function exposed by the host is a capability granted to untrusted code. Keep exposed functions narrow, validate all inputs and outputs, and avoid providing generic access such as raw HTTP, filesystem, or database operations.
High-level architecture
The system consists of two sides:
- A trusted host process
- An untrusted WebAssembly process
The two sides communicate over stdin/stdout or another byte stream.
flowchart LR
subgraph NODE["node"]
DR["duplex-rpc<br/><i>(TypeScript)</i>"]
end
subgraph WT["wasmtime"]
DRR["Duplex PRC implementation<br/><i>(any language that can compile to WASM)</i>"]
end
DR -- "stdin" --> DRR
DRR -- "stdout" --> DRThe protocol is bi-directional:
- The host can request that the WebAssembly side executes a function.
- The WebAssembly side can return results or errors.
- The WebAssembly side can request that the host executes an allowed callback function.
- Both sides can exchange stream events.
This allows the WebAssembly program to perform asynchronous workflows without receiving broad host access. For example, instead of giving WebAssembly raw HTTP access, the host may expose a narrow callback such as getProductDetails(productId).
Wire format
Messages are encoded as MessagePack on the wire.
For readability, this README shows messages as JSON-like examples. These examples describe the logical message structure, not the textual wire representation.
The version byte allows the protocol to evolve in the future. Receivers must reject frames with an unrecognized version.
Each frame contains exactly one protocol message.
packet
title Frame layout
0-7: "Version (always 1)"
8-39: "Payload Length in bytes"
40-95: "MessagePack payload"Protocol concepts
IDs
Every function call and every stream is identified by a unique string id. The caller generates the ID before sending a message.
- For function calls, the
idlinks aFunctionCallmessage to its correspondingFunctionResponseorFunctionError. This ID is generated by the caller and must be unique over the lifetime of a WebAssembly application instance. - For streams, the
idlinks allStreamChunkmessages to their finalStreamEndorStreamError. The stream ID is chosen by the caller of the function from which the stream originates.
IDs must be unique within a session. The receiver uses the id to route incoming messages to the correct pending call or stream handler.
sequenceDiagram
participant Caller
participant Callee
Caller->>Callee: FunctionCall(id = "a1")
Callee-->>Caller: FunctionResponse(id = "a1")
Caller->>Callee: FunctionCall(id = "b1")
Callee-->>Caller: FunctionError(id = "b1")Function executions can interleave:
sequenceDiagram
participant Caller
participant Callee
Caller->>Callee: FunctionCall(id = "a1")
Caller->>Callee: FunctionCall(id = "b1")
Callee-->>Caller: FunctionResponse(id = "a1")
Callee-->>Caller: FunctionError(id = "b1")Every message has a numeric type field that identifies its kind.
| Type | Name | Direction | Purpose |
| ---- | ------------------ | --------------- | ------------------------------------------------ |
| 0 | FunctionCall | both directions | Request execution of a named function. |
| 1 | FunctionResponse | both directions | Report successful completion of a function call. |
| 2 | FunctionError | both directions | Report failed completion of a function call. |
| 3 | StreamChunk | both directions | Deliver one stream item. |
| 4 | StreamEnd | both directions | Signal successful stream completion. |
| 5 | StreamError | both directions | Signal failed stream completion. |
FunctionCall (type: 0)
Requests execution of a named function.
| Field | Type | Required | Description |
| ----------------- | ------- | -------- | ------------------------------------------------------------------------------------------------- |
| type | 0 | yes | Message type discriminator. |
| id | string | yes | Unique identifier for this call. |
| functionName | string | yes | Name of the function to invoke. |
| params | JSON | no | Arguments for the function. |
| expectsResponse | boolean | no | Set to false for fire-and-forget calls. Omit when a response is expected, which is the default. |
Example:
{
"type": 0,
"id": "a1",
"functionName": "add",
"params": [1, 2],
}FunctionResponse (type: 1)
Indicates that a function call completed successfully.
| Field | Type | Required | Description |
| -------- | ------ | -------- | --------------------------------------- |
| type | 1 | yes | Message type discriminator. |
| id | string | yes | ID of the corresponding FunctionCall. |
| result | JSON | no | Return value of the function, if any. |
Example:
{
"type": 1,
"id": "a1",
"result": 3,
}FunctionError (type: 2)
Indicates that a function call failed.
| Field | Type | Required | Description |
| ------- | ------ | -------- | --------------------------------------- |
| type | 2 | yes | Message type discriminator. |
| id | string | yes | ID of the corresponding FunctionCall. |
| error | string | yes | Human-readable error message. |
Example:
{
"type": 2,
"id": "b1",
"error": "Division by zero",
}StreamChunk (type: 3)
Delivers one chunk of data within a stream.
| Field | Type | Required | Description |
| ------- | ------ | -------- | --------------------------------------- |
| type | 3 | yes | Message type discriminator. |
| id | string | yes | ID of the stream this chunk belongs to. |
| chunk | JSON | yes | The data for this chunk. |
Example:
{
"type": 3,
"id": "stream-1",
"chunk": {
"name": "Hammer",
},
}StreamEnd (type: 4)
Signals that a stream has completed successfully.
A StreamEnd is not sent when a stream ends with an error. Use StreamError instead.
| Field | Type | Required | Description |
| ------ | ------ | -------- | --------------------------- |
| type | 4 | yes | Message type discriminator. |
| id | string | yes | ID of the completed stream. |
Example:
{
"type": 4,
"id": "stream-1",
}StreamError (type: 5)
Signals that a stream has ended with an error.
| Field | Type | Required | Description |
| ------- | ------ | -------- | ----------------------------- |
| type | 5 | yes | Message type discriminator. |
| id | string | yes | ID of the failed stream. |
| error | string | yes | Human-readable error message. |
Example:
{
"type": 5,
"id": "stream-1",
"error": "Connection lost",
}Examples
Simple function call and response
The host asks WebAssembly to run a function and receives a result:
// Host → WebAssembly
{ "type": 0, "id": "a1", "functionName": "add", "params": [1, 2] }
// WebAssembly → Host
{ "type": 1, "id": "a1", "result": 3 }Note: params and result can be any JSON value, including primitive values, objects, and arrays.
Function call that returns an error
// Host → WebAssembly
{ "type": 0, "id": "b1", "functionName": "divide", "params": [10, 0] }
// WebAssembly → Host
{ "type": 2, "id": "b1", "error": "Division by zero" }Fire-and-forget call
The caller sets expectsResponse to false. The callee must not send a response or error for this ID.
// Host → WebAssembly
{
"type": 0,
"id": "c1",
"functionName": "logEvent",
"params": { "event": "started" },
"expectsResponse": false,
}WebAssembly callback to the host
WebAssembly can call back into the host using the same message types. The host decides which callbacks are allowed.
// WebAssembly → Host
{ "type": 0, "id": "d1", "functionName": "getProductDetails", "params": { "productId": "p-42" } }
// Host → WebAssembly
{ "type": 1, "id": "d1", "result": { "name": "Broccoli", "price": 6.99 } }The host must validate that getProductDetails is allowed before executing it.
Streaming data
A stream delivers multiple chunks followed by a StreamEnd. The stream ID is chosen by the caller of the function that returns or consumes the stream.
The stream data can flow before and after the creating function returned.
sequenceDiagram
participant Host
participant WASM as WebAssembly
Host->>WASM: FunctionCall(id = "e1", functionName = "listItems", params = { toolStreamId: "t1" })
WASM-->>Host: FunctionResponse(id = "e1")
WASM-->>Host: StreamChunk(id = "t1", chunk = "Hammer")
WASM-->>Host: StreamChunk(id = "t1", chunk = "Wrench")
WASM-->>Host: StreamEnd(id = "t1")// Host → WebAssembly (call a function that produces a stream)
{ "type": 0, "id": "e1", "functionName": "listItems", "params": { "category": "tools", "toolStreamId": "t1" } }
// The function might return before, while, or after the stream is active.
// WebAssembly → Host (function returns without a result, but the stream is active)
{ "type": 1, "id": "e1" }
// WebAssembly → Host (stream chunks sharing the same id)
{ "type": 3, "id": "t1", "chunk": { "name": "Hammer" } }
{ "type": 3, "id": "t1", "chunk": { "name": "Wrench" } }
// WebAssembly → Host (stream completed)
{ "type": 4, "id": "t1" }If something goes wrong mid-stream, a StreamError is sent instead of StreamEnd:
{ "type": 5, "id": "t1", "error": "Connection lost" }Failure handling
The host should terminate the WebAssembly process and discard its state when:
- A frame cannot be decoded.
- A frame uses an unrecognized protocol version.
- A message does not match the protocol schema.
- WebAssembly requests an unauthorized callback.
- WebAssembly references an unknown
id. - A call exceeds its timeout.
- The process exits unexpectedly.
- The output stream contains non-protocol data.
After termination, the host should start a fresh WebAssembly process for subsequent work.
flowchart TD
message["Receive frame"]
decode["Decode frame and MessagePack payload"]
validate["Validate protocol message"]
authorized["Authorized operation?"]
execute["Execute or route message"]
terminate["Terminate WebAssembly process<br/>discard state<br/>start fresh process if needed"]
message --> decode
decode -->|"decode failed"| terminate
decode -->|"ok"| validate
validate -->|"invalid schema"| terminate
validate -->|"ok"| authorized
authorized -->|"no"| terminate
authorized -->|"yes"| executeWhy an external Wasmtime process and not node's built-in WASM APIs?
This project intentionally communicates with WebAssembly through an external Wasmtime process instead of embedding WebAssembly directly through Node's WebAssembly APIs.
The goal is not to make every function call as cheap as possible. The goal is to create a simple, inspectable, process-isolated boundary for larger untrusted components.
Key reasons:
Stronger isolation boundary
The WebAssembly program runs in a separate process. If it hangs, crashes, violates the protocol, or exceeds a timeout, the host can terminate the process and start fresh.Simpler communication model
Direct WebAssembly embedding usually requires manual memory management: allocating memory inside the WebAssembly instance, writing bytes into linear memory, passing pointer/length pairs, and reading results back out. With an external process, communication is just framed messages over stdin/stdout.Language-independent protocol
The protocol is not tied to TypeScript or Node. Any host language that can start a subprocess and read/write byte streams can speak the same protocol.Runtime flexibility
Although this project targets Wasmtime, the protocol itself is just MessagePack frames over a byte stream. In principle, any external command-line application could implement the same protocol, whether or not it is backed by WebAssembly.Good fit for coarse-grained logic
This RPC boundary is not intended for tiny high-frequency helper functions. Simple logic should usually stay in the host application. The model is better suited for larger components such as parsers, analyzers, planners, validators, transformers, ranking algorithms, or other logic where the call overhead is small compared with the work performed.Testable black-box behavior
The WebAssembly component can be treated as a black box. The host can run integration tests and property-based tests against the protocol boundary without needing to trust or inspect the internal implementation.Capability-oriented design
The WebAssembly side cannot simply perform arbitrary host actions. If it needs external data or effects, it must request them through explicit RPC calls that the host can validate, authorize, deny, log, or simulate in tests.
In short: this approach favors a clear trust boundary, protocol validation, process isolation, and implementation flexibility over ultra-low-latency in-process calls.
