117 lines
6.1 KiB
Markdown
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. |