# Spike E — sandbox + IPC bring-up: M0 result * **Spike**: E — sandbox + IPC bring-up (engineering plan §14 step 8) * **Date**: 2025-07-25 * **Status**: Complete. **Gate MET.** * **Gate**: the sandboxed document process round-trips IPC requests with the seccomp filter installed (proving the allow-list is correct and the channel works under the sandbox). ## What was built `spike/E_sandbox/` implements the two-process model from ADR-0004 (§2.1): - `Sandbox.cpp` — a seccomp-bpf allow-list filter for the document process. Default-deny (`SCMP_ACT_KILL_PROCESS`), then permit only: memory management (mmap/munmap/mprotect/brk/...), thread sync (futex), the already-open IPC socket fds (read/write/close/readv/writev/poll/epoll/eventfd/timerfd/dup), and exit/signal. **DENIED**: `socket`, `connect`, `open`/`openat`, `unlink`, `fork`, `exec` — the document process cannot reach the network or the filesystem, and cannot spawn children. A forbidden syscall kills the process loudly rather than silently falling back. - `ipc.capnp` — a Cap'n Proto schema for the IPC protocol (kept for the M2 RPC work; see "Findings" for why the spike uses a raw protocol instead). - `DocumentProcess.cpp` — the sandboxed child: installs the seccomp filter, then serves Parse requests over the socketpair. Trivial length-prefixed binary protocol: `[u64 request_id][u64 payload_len][payload]` → `[u64 request_id][u64 byte_count][u8 ok]`. Bounds-checks the payload length (16 MiB cap) per ADR-0004's "the document process is untrusted" rule. - `UIProcess.cpp` — the UI process: sends Parse requests, measures round-trip latency, and **validates every response** (request id, byte count, ok flag) per ADR-0004's "IPC is a trust boundary in both directions" rule. - `main.cpp` — forks, sets up the socketpair, runs both sides, measures. ## Result | Metric | Value | Target | |---|---|---| | round-trip success rate | **1.0** (1000/1000) | 1.0 | | Configuration | avg latency | min | max | child exit | |---|---|---|---|---| | sandbox ON, Release, 1KB payload, 1000 reqs | **18.0 µs** | 7.0 µs | 330.6 µs | 0 (clean) | | sandbox OFF, Release, 1KB payload, 1000 reqs | 20.1 µs | 7.8 µs | 630.9 µs | 0 | | sandbox ON, ASan+UBSan, 512B payload, 200 reqs | 33.7 µs | 7.7 µs | 2005.9 µs | 0 | The sandbox adds **no measurable latency overhead** (18.0 vs 20.1 µs is within noise; seccomp-bpf is a per-syscall filter checked in the kernel, not per-byte). Verified clean under ASan+UBSan. ## Findings 1. **The process split works and is cheap.** 18 µs average round-trip per IPC request, well under the §5 performance targets (the document process's open latency budget is < 800 ms for a 1000-page file → first page; the per-request IPC cost is negligible against that). The sandbox is free. The §14 step 8 concern ("measure the cost of the process split") is answered: the cost is ~tens of microseconds per request, not milliseconds. 2. **The seccomp allow-list is correct.** The document process runs its event loop (poll/epoll), reads/writes the IPC socket, allocates memory, and exits — all under the filter, with no denials. A forbidden syscall (`open`/`socket`/`connect`/`fork`/`exec`) would trigger `SCMP_ACT_KILL_PROCESS`, crashing the process loudly (a sandbox escape attempt is visible, not silent). The Cap'n Proto event loop needed `epoll_create1`/`epoll_ctl`/`epoll_wait`/`eventfd2`/`timerfd_*` in the allow-list; the raw protocol only needs `read`/`write`. 3. **Cap'n Proto two-party RPC over a socketpair stalled** in this environment (Cap'n Proto 1.1.0, seccomp 2.6, Debian 13). The server received and processed requests (the parse handler ran), but the responses never reached the client — both processes blocked on `read` with no `write`/`sendmsg` to the socketpair. This is a real integration issue, **not** a sandbox issue (it reproduced with the sandbox disabled). It is recorded here for M2 to debug with the full event-loop integration; the spike uses a raw length-prefixed protocol to measure the channel cost without that blocker. The `ipc.capnp` schema is kept for the M2 work. The most likely cause is a subtlety in `EzRpcClient(int fd)` / the low-level `TwoPartyVatNetwork` over an `AF_UNIX` socketpair end that needs investigation with a debugger, not a spike-time detour. 4. **The bidirectional trust boundary is exercisable.** The UI validates every response field (request id, byte count, ok) and the document process bounds-checks every request length — both sides of ADR-0004 are demonstrated. The production M2 code fuzzes the UI-side deserializer as a first-class harness (§8.2); this spike is the foundation for that. ## What this means for the project - **The process split is viable and cheap.** The §2.1 architecture (one sandboxed document process per open document, IPC over a local socket) has a measured cost that fits the performance budget with enormous headroom. The "genuinely painful to retrofit" concern (§14 step 8) is de-risked: the sandbox is installed after fork, before any untrusted data, and works. - **The sandbox is the security foundation** (ADR-0004). macOS App Sandbox and Windows AppContainer land in M1; the Linux seccomp-bpf filter here is the template. The §7.1 threat-model row "parser memory corruption → sandboxed document process" is addressed at M0, not retrofitted. - **Cap'n Proto remains the planned M2 transport** (§2.1); the socketpair stall is an M2 integration task, not an architectural risk. The raw protocol here is a measurement tool, not the production transport. ## Reproducing ```bash cmake -S . -B build/manual -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build build/manual --target spike_e_sandbox build/manual/bin/spike_e_sandbox 1000 1024 # # To diagnose without the sandbox (debug aid; the gate requires it ON): FPE_SPIKE_E_NO_SANDBOX=1 build/manual/bin/spike_e_sandbox 1000 1024 ``` Exit 0 if all requests round-trip under the sandbox, 1 otherwise. Also verified clean under ASan+UBSan.