6.1 KiB
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
-
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.
-
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 triggerSCMP_ACT_KILL_PROCESS, crashing the process loudly (a sandbox escape attempt is visible, not silent). The Cap'n Proto event loop neededepoll_create1/epoll_ctl/epoll_wait/eventfd2/timerfd_*in the allow-list; the raw protocol only needsread/write. -
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
readwith nowrite/sendmsgto 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. Theipc.capnpschema is kept for the M2 work. The most likely cause is a subtlety inEzRpcClient(int fd)/ the low-levelTwoPartyVatNetworkover anAF_UNIXsocketpair end that needs investigation with a debugger, not a spike-time detour. -
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
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 # <n_requests> <payload_bytes>
# 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.