Skip to content

Commit 7ce9372

Browse files
wan9chicodex
andcommitted
docs(fspy): record Chromium sandbox boundary
Co-authored-by: GPT-5 Codex <codex@openai.com>
1 parent b414304 commit 7ce9372

4 files changed

Lines changed: 90 additions & 51 deletions

File tree

.github/workflows/fspy-sigsys-research.yml

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
./nonleader_exec /bin/true
4949
5050
vitest-browser-x86:
51-
name: Sandboxed Vitest browser under transient ptrace
51+
name: Chromium sandbox compatibility boundary
5252
runs-on: ubuntu-24.04
5353
timeout-minutes: 15
5454
steps:
@@ -85,20 +85,36 @@ jobs:
8585
pnpm --filter vite-task-tools exec node --input-type=module \
8686
--eval "$sandbox_control"
8787
88-
- name: Run sandboxed Vitest browser mode through the exec bridge
88+
- name: Validate Vitest browser modes through the exec bridge
8989
run: |
9090
set -euxo pipefail
9191
test "$(uname -m)" = x86_64
9292
9393
make -C research/ptrace-exec-prototype recursive_injector
9494
probe_dir=$(mktemp -d)
9595
cp -a crates/vite_task_bin/tests/e2e_snapshots/fixtures/vitest_browser_cache \
96-
"$probe_dir/fixture"
96+
"$probe_dir/default"
97+
cp -a crates/vite_task_bin/tests/e2e_snapshots/fixtures/vitest_browser_cache \
98+
"$probe_dir/sandboxed"
9799
ln -s "$PWD/packages/tools/node_modules" "$probe_dir/node_modules"
98100
99101
injector="$PWD/research/ptrace-exec-prototype/recursive_injector"
100102
vitest="$PWD/packages/tools/node_modules/.bin/vitest"
101-
cd "$probe_dir/fixture"
103+
104+
cd "$probe_dir/default"
105+
VITEST_CHROMIUM_SANDBOX=false \
106+
DEBUG=vitest:browser:playwright,pw:browser \
107+
timeout 120s "$injector" "$vitest" run \
108+
2>&1 | tee "$probe_dir/default.log"
109+
test -s dist/result.json
110+
grep -F '"success":true' dist/result.json
111+
grep -F 'chromiumSandbox: false' "$probe_dir/default.log"
112+
grep -E 'bridge: injected exec .*exe=.*/(chrome|headless_shell)' \
113+
"$probe_dir/default.log"
114+
grep -E 'bridge: summary injected_execs=[2-9][0-9]* failed_execs=0' \
115+
"$probe_dir/default.log"
116+
117+
cd "$probe_dir/sandboxed"
102118
103119
sandbox_record="$probe_dir/chromium-sandbox.txt"
104120
no_sandbox_record="$probe_dir/chromium-no-sandbox.txt"
@@ -150,23 +166,21 @@ jobs:
150166
151167
kill "$observer_pid" 2>/dev/null || true
152168
wait "$observer_pid" 2>/dev/null || true
153-
if test "$run_status" -ne 0; then
154-
sudo dmesg | tail -200 || true
155-
fi
156-
test "$run_status" -eq 0
169+
test "$run_status" -ne 0
157170
158171
test -s dist/result.json
159-
grep -F '"success":true' dist/result.json
172+
grep -F '"success":false' dist/result.json
160173
grep -F 'chromiumSandbox: true' "$probe_dir/bridge.log"
161174
test ! -s "$no_sandbox_record"
162-
test -s "$sandbox_record"
163-
grep -F $'NoNewPrivs:\t1' "$sandbox_record"
164-
grep -F $'Seccomp:\t2' "$sandbox_record"
165-
grep -E $'Seccomp_filters:\t([2-9]|[1-9][0-9]+)' "$sandbox_record"
166-
cat "$sandbox_record"
167-
grep -E 'bridge: injected exec .*exe=.*/(chrome|headless_shell)' \
175+
test ! -s "$sandbox_record"
176+
grep -E 'bridge: exec argv .* --type=zygote ' "$probe_dir/bridge.log"
177+
grep -F 'bridge: zygote fd 3=socket:' "$probe_dir/bridge.log"
178+
grep -F 'fspy: recvmsg returned EOF' "$probe_dir/bridge.log"
179+
grep -F 'FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:207' \
168180
"$probe_dir/bridge.log"
169-
grep -E 'bridge: summary injected_execs=[2-9][0-9]* failed_execs=' \
181+
grep -F 'ZygoteMain: initializing' "$probe_dir/bridge.log"
182+
grep -F 'write: Broken pipe (32)' "$probe_dir/bridge.log"
183+
grep -E 'bridge: summary injected_execs=[2-9][0-9]* failed_execs=0' \
170184
"$probe_dir/bridge.log"
171185
172186
docker-x86:

docs/fspy-linux-sigsys-research.md

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Research date: 2026-08-02
44

5-
Status: feasibility proven on native Linux AArch64 and x86-64, including Vitest browser mode and Docker's default seccomp and AppArmor profiles
5+
Status: feasibility proven on native Linux AArch64 and x86-64, including default Vitest browser mode and Docker's default seccomp and AppArmor profiles. Playwright's `chromiumSandbox: true` is a confirmed compatibility boundary for the current ptrace exec bridge.
66

77
Primary audience: fspy maintainers deciding whether to replace the Linux `LD_PRELOAD` and seccomp user-notification backends.
88

@@ -13,7 +13,8 @@ The design is feasible for fspy's unprivileged build-tool workload. Use `SECCOMP
1313
Use two exec bridges, selected by a startup probe:
1414

1515
1. Prefer a temporary ptrace attachment around a real target exec. This preserves kernel ELF, script, identity, and failure semantics.
16-
2. If ptrace is denied or already owned, real-exec a static `fspy_host` and load the target in user space. This path works for the tested frontend workload but has a narrower, documented exec contract.
16+
2. Do not use that bridge for Chromium's namespace-sandbox zygote exec. The current bridge breaks Chromium's synchronous zygote boot handshake before Chromium installs its seccomp policy.
17+
3. If ptrace is denied, already owned, or incompatible with a namespace-sandbox launch, real-exec a static `fspy_host` and load the target in user space. This path works for the tested frontend workload but has a narrower, documented exec contract. Sandboxed Chromium still needs a dedicated userland-loader validation.
1718

1819
The preferred ptrace bridge is:
1920

@@ -67,7 +68,8 @@ The primary experiments ran on Ubuntu 24.04 AArch64, Linux 6.8, in a four-vCPU L
6768
| Can nested and concurrent traps work? | Yes; `SA_NODEFER`, a nested trap, and 200,000 calls from four threads passed |
6869
| Can Go replace or block `SIGSYS` without breaking fspy? | The prototype virtualized the tested `rt_sigaction` and `rt_sigprocmask` operations; esbuild passed |
6970
| Can a real exec regain the handler before target entry? | Yes; post-exec ptrace injection passed for dynamic, static, non-leader, and esbuild execs |
70-
| Can a recursive on-demand ptrace bridge run Vitest browser mode? | Yes; Vitest 4.1.10 and Playwright Chromium passed through nine exec reinjections on native x86-64 |
71+
| Can a recursive on-demand ptrace bridge run Vitest browser mode? | Yes with Playwright's default `--no-sandbox`; nine exec reinjections passed on native x86-64 |
72+
| Does the same bridge support `chromiumSandbox: true`? | No; the namespace zygote handshake reaches EOF at its ptrace exec boundary before Chromium seccomp |
7173
| Can the proposed static-host cycle bootstrap under the inherited filter? | Yes; a real exec into a static-musl second stage reinstalled the handler through the trusted gateway |
7274
| Can a pure handoff run frontend tools? | Yes; Node and esbuild 0.28.1 CLI/API paths, static Go, static musl, shells, and coreutils passed |
7375

@@ -159,7 +161,33 @@ A subtle signal-mask rule is mandatory. `SIGSYS` is automatically blocked while
159161

160162
The native x86-64 [Vitest browser validation](https://github.com/voidzero-dev/vite-task/actions/runs/30735989574) ran the repository's real fixture with Node 22.19.0, Vitest 4.1.10, `@vitest/browser-playwright` 4.1.10, Playwright 1.61.1, and Chrome Headless Shell 149.0.7827.55. It reinjected nine successful images—dash, sed, dirname, uname, Node, and four Chromium processes—with zero failed execs. The browser test and JSON report passed while the inherited filter trapped and reissued the representative file-syscall set: `openat`, `openat2`, `newfstatat`, `statx`, `getdents64`, `faccessat`, and `faccessat2`.
161163

162-
This proves compatibility with Vitest's default Playwright Chromium launch, which disables Chromium's sandbox unless `chromiumSandbox: true` is requested. It does not yet prove coexistence with Chromium's own sandbox seccomp policy and `SIGSYS` handler.
164+
This proves compatibility with Vitest's default Playwright Chromium launch, which disables Chromium's sandbox unless `chromiumSandbox: true` is requested.
165+
166+
### `chromiumSandbox: true` fails at the namespace-zygote exec boundary
167+
168+
The current ptrace exec bridge does not support Playwright's `chromiumSandbox: true`. This is a confirmed negative result, not a missing host prerequisite.
169+
170+
The native Ubuntu 24.04 validation established these controls:
171+
172+
- A direct Playwright launch with `chromiumSandbox: true` passed.
173+
- The same direct launch under `setpriv --no-new-privs` passed.
174+
- The full bridge continued to pass Vitest with Playwright's default `chromiumSandbox: false`.
175+
- The sandboxed launch used no `--no-sandbox` argument.
176+
177+
Ubuntu 24.04's AppArmor policy restricts unprivileged user namespaces by default on the GitHub runner. The validation explicitly set `kernel.apparmor_restrict_unprivileged_userns=0`. Without that host setup, Chromium fails earlier with `No usable sandbox`; that is a separate environment failure.
178+
179+
With the host prerequisite satisfied, the bridged launch fails in this order:
180+
181+
1. The bridge injects the main Chrome image successfully.
182+
2. Chrome creates its namespace zygote and the bridge injects that exec successfully. `/proc/<zygote>/fd/3` still reports the inherited Unix socket.
183+
3. The browser's blocking `recvmsg` returns zero at the zygote exec boundary. Chromium fails `ReceiveFixedMessage` at `zygote_host_impl_linux.cc:207` before receiving `ZYGOTE_BOOT`.
184+
4. The zygote reaches `ZygoteMain` after detach. Its later control-socket write reports `EPIPE` because the browser has already abandoned the handshake.
185+
186+
The [full-filter diagnostic run](https://github.com/voidzero-dev/vite-task/actions/runs/30737025514) captured the zero-length receive, the preserved fd 3, and the later broken pipe. An [exec-and-signal-only filter run](https://github.com/voidzero-dev/vite-task/actions/runs/30737143580) failed at the same point. The representative file-syscall traps are therefore not the cause.
187+
188+
Chromium's own seccomp policy is not active at this failure point. Forwarding a foreign `SECCOMP_RET_TRAP` to the target's logical `SIGSYS` handler and installing fspy's physical handler with `SA_NODEFER` are still required. The standalone nested-filter test validates those mechanics, but they do not fix this earlier zygote handshake.
189+
190+
Treat namespace-sandbox zygote exec as a ptrace incompatibility until a different rendezvous proves otherwise. A production hybrid should route this exec through the static-host userland loader or decline tracing with an actionable error. It must make that choice before attempting the ptrace exec because Chromium treats the failed boot handshake as fatal.
163191

164192
This path preserves the parts of exec that frontend tools depend on:
165193

@@ -230,16 +258,16 @@ The static host also needs a target-independent payload ABI. The current `Payloa
230258
| Environment | RET_TRAP syscall path | Temporary ptrace exec | Evidence or required handling |
231259
| ---------------------------- | --------------------- | ------------------------- | --------------------------------------------------------------------- |
232260
| Native Linux AArch64 | Passed | Passed | Ubuntu 24.04/Linux 6.8; dynamic, static, esbuild, and non-leader exec |
233-
| Native Linux x86-64 | Passed | Passed | Ubuntu 24.04 GitHub runner; dynamic, non-leader, Node, and Chromium |
261+
| Native Linux x86-64 | Passed | Passed with boundary | Ubuntu 24.04; default Chromium passes, namespace-sandbox zygote fails |
234262
| WSL2 | Expected | Expected, untested | Avoid `NEW_LISTENER`; test mirrored networking and ptrace policy |
235263
| Rootless containerd | Passed | Passed | Existing seccomp filter; also passed with `no_new_privs=1` |
236264
| Docker default on Linux | Passed | Passed | Existing filter, `no_new_privs=1`, enforced default AppArmor |
237265
| Docker Desktop amd64/Rosetta | Unsupported | Not reached | Local `PR_SET_SECCOMP` returned `EINVAL`; use a native-arch image |
238266
| Kubernetes | Runtime-dependent | Runtime and LSM-dependent | Probe and fall back; test containerd/CRI-O RuntimeDefault profiles |
239-
| Hosted CI | Passed on GitHub | Passed on GitHub | Other providers still need the startup probe |
267+
| Hosted CI | Passed on GitHub | Passed with boundary | GitHub passes except sandboxed Chromium; other providers need a probe |
240268
| Custom sandbox | Policy-dependent | Often denied | Use the userland fallback or report an actionable error |
241269

242-
The latest native x86-64, Vitest browser, and Docker evidence is recorded in [GitHub Actions run 30735989574](https://github.com/voidzero-dev/vite-task/actions/runs/30735989574). The Docker recursive bridge passed with `Seccomp: 2`, `NoNewPrivs: 1`, and `docker-default (enforce)`. The Rosetta result is an emulation limitation, not a failure of native x86-64 Docker.
270+
The default-browser native x86-64 and Docker evidence is recorded in [GitHub Actions run 30735989574](https://github.com/voidzero-dev/vite-task/actions/runs/30735989574). The Docker recursive bridge passed with `Seccomp: 2`, `NoNewPrivs: 1`, and `docker-default (enforce)`. The sandboxed-Chromium boundary is recorded separately in [run 30737025514](https://github.com/voidzero-dev/vite-task/actions/runs/30737025514). The Rosetta result is an emulation limitation, not a failure of native x86-64 Docker.
243271

244272
The open [WSL issue about seccomp notification](https://github.com/microsoft/WSL/issues/9548) concerns the single `NEW_LISTENER` restriction when WSL mirrored networking already owns a listener. It does not prevent stacking a normal `RET_TRAP` filter. The current WSL kernel configuration enables seccomp filtering.
245273

research/ptrace-exec-prototype/README.md

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,26 @@ never reaches `rt_sigreturn`, so the new image otherwise inherits `SIGSYS`
7676
blocked and dies on its first trapped syscall. The handler must explicitly
7777
unblock physical `SIGSYS` immediately before issuing the gateway exec.
7878

79-
The Vitest fixture uses Playwright's default unsandboxed Chromium launch. A
80-
future test must enable `chromiumSandbox: true` to validate interaction with
81-
Chromium's own seccomp policy and logical `SIGSYS` handler.
79+
The default-browser result does not extend to Playwright's
80+
`chromiumSandbox: true`. Native Ubuntu 24.04 controls launched sandboxed
81+
Chromium both normally and under `no_new_privs`. Through the bridge, however,
82+
the namespace zygote exec leaves fd 3 present while the browser side of the
83+
boot socket observes EOF. Chromium aborts at
84+
`zygote_host_impl_linux.cc:207`; the zygote later reaches `ZygoteMain` and gets
85+
`EPIPE` after the browser closes its end.
86+
87+
The [full-filter run](https://github.com/voidzero-dev/vite-task/actions/runs/30737025514)
88+
captured that sequence. An
89+
[exec-and-signal-only run](https://github.com/voidzero-dev/vite-task/actions/runs/30737143580)
90+
failed at the same boundary, so the file-syscall passthrough is not the cause.
91+
The failure precedes Chromium's own seccomp setup. The nested-filter test still
92+
proves that fspy can identify its filter tag, forward a foreign seccomp trap to
93+
the target's logical `SIGSYS` action, and permit reentrant delivery with
94+
`SA_NODEFER`.
95+
96+
Do not use this ptrace bridge for Chromium namespace-zygote execs. A production
97+
hybrid must select the static-host userland loader before attempting that exec,
98+
or reject the launch with an actionable compatibility error.
8299

83100
## Prototype boundaries
84101

@@ -102,6 +119,7 @@ Chromium's own seccomp policy and logical `SIGSYS` handler.
102119
- The recursive prototype uses direct pointer loads while virtualizing signal
103120
APIs and only reissues file syscalls; it does not yet perform fault-safe path
104121
capture or write fspy events to shared memory.
105-
- Its logical `SIGSYS` model is intentionally incomplete. Per-thread virtual
106-
masks, delivery to a target-installed logical handler, and coexistence with
107-
another seccomp `TRAP` producer remain production work.
122+
- Its logical `SIGSYS` model is intentionally incomplete. The prototype
123+
forwards a tagged foreign trap to the tested target handler, but per-thread
124+
virtual masks, complete action semantics, and general coexistence with other
125+
seccomp producers remain production work.

research/ptrace-exec-prototype/recursive_injector.c

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -153,33 +153,13 @@ __asm__(
153153
" mov (%r13,%rcx), %rsi\n"
154154
" mov fspy_recursive_slot_rdx(%rip), %rcx\n"
155155
" mov (%r13,%rcx), %rdx\n"
156-
" mov 40(%rsi), %r8\n" /* preserve msghdr.msg_controllen */
157-
" xor %r10d, %r10d\n" /* one zero-length record may be skipped */
158-
".Lfspy_recvmsg_retry:\n"
159156
" mov fspy_recursive_slot_magic(%rip), %r9\n"
160157
" mov $47, %eax\n" /* __NR_recvmsg */
161158
" syscall\n"
162159
" mov %rax, %r15\n"
163160
" test %rax, %rax\n"
164161
" js .Lfspy_recvmsg_error\n"
165-
" jnz .Lfspy_recvmsg_positive\n"
166-
" test %r10d, %r10d\n"
167-
" jnz .Lfspy_recvmsg_eof\n"
168-
" mov $1, %r10d\n"
169-
" mov %r8, 40(%rsi)\n"
170-
" lea .Lfspy_recvmsg_retry_message(%rip), %rsi\n"
171-
" mov $34, %edx\n"
172-
" mov $2, %edi\n"
173-
" mov $1, %eax\n" /* __NR_write */
174-
" syscall\n"
175-
" mov fspy_recursive_slot_rsi(%rip), %rcx\n"
176-
" mov (%r13,%rcx), %rsi\n"
177-
" mov fspy_recursive_slot_rdi(%rip), %rcx\n"
178-
" mov (%r13,%rcx), %rdi\n"
179-
" mov fspy_recursive_slot_rdx(%rip), %rcx\n"
180-
" mov (%r13,%rcx), %rdx\n"
181-
" jmp .Lfspy_recvmsg_retry\n"
182-
".Lfspy_recvmsg_positive:\n"
162+
" jz .Lfspy_recvmsg_eof\n"
183163
" lea .Lfspy_recvmsg_positive_message(%rip), %rsi\n"
184164
" mov $32, %edx\n"
185165
" jmp .Lfspy_recvmsg_log\n"
@@ -410,7 +390,6 @@ __asm__(
410390
".Lfspy_recvmsg_error_message: .ascii \"fspy: recvmsg returned error\\n\"\n"
411391
".Lfspy_recvmsg_eintr_message: .ascii \"fspy: recvmsg returned EINTR\\n\"\n"
412392
".Lfspy_recvmsg_enoent_message: .ascii \"fspy: recvmsg returned ENOENT\\n\"\n"
413-
".Lfspy_recvmsg_retry_message: .ascii \"fspy: recvmsg skipped zero record\\n\"\n"
414393
".balign 8\n"
415394
"fspy_recursive_slot_rax: .quad 0\n"
416395
"fspy_recursive_slot_rdi: .quad 0\n"

0 commit comments

Comments
 (0)