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

117 lines
6.1 KiB
Markdown

<!--
SPDX-License-Identifier: GPL-3.0-or-later
SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
-->
# 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 # <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.