freepdfeditor/docs/spike-results/0004-spike-e-sandbox.md

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

  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

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.