Skip to content

feat: mountx/exec - #9

Closed
pi0x wants to merge 25 commits into
mainfrom
feat/proot
Closed

feat: mountx/exec#9
pi0x wants to merge 25 commits into
mainfrom
feat/proot

Conversation

@pi0x

@pi0x pi0x commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Draft. mountx/exec — run a command with an FsDriver grafted onto its filesystem view. New subpath, deliberately not wired into mountx/auto (auto's contract is a mountpoint; this produces a child's exit status).

Started as a question — can we do a proot-style exec() without a real kernel mount? — and became three measured mechanisms, of which two ship. Full write-up in .agents/proot-plan.md.

The strategy

exec() picks A (FUSE in an unprivileged user namespace) wherever the kernel's FUSE is usable, and C (seccomp user-notification supervisor) otherwise. Following src/auto.ts's precedents exactly: the probe publishes what each mechanism can do here and why not; each arrives via await import(); no fallback after a failure (the probe decides once from host facts — "fallback" means the probe choosing C, not retrying C after A errored); no probe at all when a mechanism is named.

The structural finding

An interceptor does not need a filesystem. It needs a client. Both no-mount mechanisms translate intercepted operations into 9P against an unmodified createP9Server() — the same server mount9p() points v9fs at. Path resolution, handle lifetimes, directory paging and error mapping stay in src/9p/session.ts. That's why one Zig 9P client (src/exec/preload/p9.zig) serves two completely different interception mechanisms, and why neither contains filesystem logic.

Measured — sh test/exec/compare.sh, no root

dynamic glibc static musl raw syscalls (the Go case)
A userns + FUSE pass pass pass
B LD_PRELOAD pass fail fail
C seccomp notify pass pass pass

Write-back — asserted against the driver, not against exit status:

userns  : created appended ab removed
preload : write error: Bad file descriptor ... STILL THERE
seccomp : created appended ab removed

Bare Alpine

extra packages privileges kernel
A none (busybox unshare/mount) none — --cap-drop=ALL, uid 1000 needs CONFIG_FUSE_FS + /dev/fuse
C none (static musl binary) none — same CONFIG_SECCOMP_FILTER, built in, cannot be a module

alpine:latest has no /dev/fuse and a userns root cannot create one (mknodEPERM, verified) — which is exactly the case that selects C.

Notable findings

  • default_permissions silently made A read-only. unshare -r maps one uid; a driver reporting the serving process's real uid is an identity the namespace doesn't map, so the kernel renders it nobody and every write is EACCES — namespace-root's CAP_DAC_OVERRIDE doesn't reach an unmapped owner. Removed from the defaults; the mount carries no allow_other, so permission checking belongs to the driver.
  • C's read-only phase lost data silently. memfds are always writable and write was untrapped, so writes landed in the in-memory copy and vanished — dd conv=notrunc and rm -f both reported success and changed nothing. Now streamed per open-file-description with a dup-shared offset, and close is trapped, which required dropping the shared filter: the child installs it and passes the listener back over SCM_RIGHTS, reusing native/src/main.zig's cmsg transcription.
  • A builds the relay mode the roadmap defers. unshare(CLONE_NEWUSER) needs a single-threaded caller and Node never is, so the driver can't live in the namespace and FUSE traffic comes back out over a socket.

Coverage

test/exec/seccomp-conformance.test.ts runs the full conformance column through a traced process — test/conformance.ts unmodified, 65 passed / 1 skipped (the root-only lchown case every column skips). Plus Tier-0 supervisor tests, 24 Tier-2 real-command cases asserted against the driver, and strategy/probe tests answerable from any host. All skip cleanly without zig or off x86-64 Linux; none needs root.

pnpm test: 2885 passed, 275 skipped, 0 failed. Built package verified end to end (dist/exec/index.mjs picks userns and round-trips a write).

Deliberately not done

  • B is rejected and kept as the evidence for why, referenced by nothing but its own demo runner and the comparison harness. Its coverage excludes Go and static binaries by construction, its symbol surface tracks other projects' releases, and a descriptor it creates doesn't survive exec — a hole that cannot be closed from inside the process.
  • C: concurrent dispatch (correct but serialized), mmap of a file on the tree (ENODEV, loud), execve off the tree, arm64.
  • No conformance-matrix or bench column for A; no mountx/auto wiring.
  • macOS gets nothing — no user namespaces, no seccomp, SIP blocks DYLD_INSERT_LIBRARIES. It stays NFS-mount territory.

🤖 Generated with Claude Code

pi0 and others added 7 commits July 29, 2026 11:15
The proot-style spikes differ almost entirely in *which binaries they can
serve*, so the comparison is only honest if the workload is identical and the
linkage is the only variable. One C source built two ways (dynamic glibc,
static musl) plus a no-libc variant issuing raw `syscall` instructions, which
stands in for a Go binary without needing a Go toolchain.

`probe-raw.c` needs `-ffreestanding -fno-builtin` or the compiler turns its
hand-written loops back into calls to the libc it deliberately does not have.

`demo-driver.ts` is the tree all three spikes are pointed at, including 3 MiB
of deterministic bytes so a checksum catches a short read or a torn offset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`execUserns(driver, argv)` runs a command with an `FsDriver` visible to it and
to nothing else on the machine: a FUSE mount made inside `unshare -Urm`, where
`getuid()` is 0 and `src/fuse/mount.ts`'s ordinary root path works verbatim —
no `fusermount3`, no native addon, no privileges. The mount never appears in
the host's mount table and dies with the namespace.

The driver stays in the parent because it has to. `unshare(CLONE_NEWUSER)`
requires a single-threaded caller and Node never is; `setns(2)` has the same
rule. So a helper enters the namespace, holds `/dev/fuse`, and pumps raw FUSE
traffic over a unix socket to a `FuseSession` here — which is the "relay mode"
the roadmap defers, arrived at from the other direction. FUSE messages are
self-framing, so the socket needs no envelope of its own.

Two hazards witnessed and handled: giving the spawned command a `cwd` inside
the mount wedges the relay in `D` state at `fuse_get_req` forever (the
documented `uv_spawn` deadlock, met from the inside), and a killed parent
otherwise orphans a wedged mount.

Spawns `unshare -U -r -m` rather than the long options: busybox 1.37 has `-r`
and no `--map-root-user` at all, and this is verified working on bare Alpine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ocess

The structural finding behind both no-mount spikes: a syscall interceptor does
not need a filesystem, it needs a *client*. Path resolution, handle lifetimes,
directory paging and error mapping are already settled by `src/9p/session.ts`
and already covered by a conformance column, so an interceptor that speaks 9P
is a wire adapter with an fd table and no filesystem logic at all.

Constants and message layouts are transcribed from `src/9p/constants.ts` and
`src/9p/protocol.ts`, same rule the TypeScript side is held to. Socket I/O goes
through raw `syscall` instructions rather than libc, because in the
`LD_PRELOAD` case this code *is* libc's `read`/`write` and calling them would
re-enter the shim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kept as evidence, not as a direction. It works — `ls -la`, `cat`, `grep`,
`tail`, `sha256sum`, `find`, `du` and a full `cp -r` all behave across 49
exported symbols — and it should not be shipped. Nothing references it outside
its own runner and the comparison harness.

Getting there took seven discoveries whose pattern is the finding: `statx` is
reached internally without a PLT hop; `fopen` bypasses `read` entirely (the
obvious bridge made `sha256sum` return the hash of the empty string with exit
status 0); `__read_chk` is a separate symbol under the fortification that is
default on Debian, Fedora and Ubuntu; `fdopendir` is where `find` dies; `*at()`
resolution is needed in three symbols independently; `fts` dups the directory
fd; `getxattr` must be answered or `ls -l` marks every mode string `?`.

So the surface is the POSIX names crossed with the `64` suffix, the `__*_chk`
suffix and the legacy `__xstat` forms, with the landing site fixed by the glibc
a program was *compiled* against — a maintenance surface that grows with other
projects' releases.

And one hole cannot be closed from inside the process: a descriptor this shim
creates does not survive `exec`, because the fd number is inherited while the
table backing it is in memory `exec` discards. `wc -l < file` silently reads
empty. Every one of its worst failures is a confident wrong answer rather than
an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
proot done the modern way, and the mechanism that actually answers the
question. A BPF filter traps eight filesystem syscalls and everything else runs
natively without leaving the kernel. Because the boundary is the syscall ABI
rather than glibc's exported symbols, the traced program's linkage is
invisible: a static musl binary and a no-libc raw-syscall binary — neither of
which spike B can see at all — pass byte-exact.

No descriptor is passed anywhere. The usual shape forks, has the child install
the filter and hands the listener back over `SCM_RIGHTS`; a filter is inherited
across fork and exec, so the supervisor installs on *itself* and keeps the
listener. The price is a rule the file must keep — after installing, the
supervisor may never make a trapped syscall, or it suspends waiting for a reply
only it can send. Violating it (an `open("/dev/null")` in the openat handler)
hung everything the first time anything opened a directory.

An `openat` of a regular file is answered by slurping it over 9P into a `memfd`
and injecting that with `SECCOMP_IOCTL_NOTIF_ADDFD`, so `read`/`lseek`/`mmap`
afterwards are native and untrapped. The cost is stated rather than hidden:
whole file in memory, no write-back, read-only as spiked.

Also measured: `fstat` is its own syscall number and `opendir` checks with it;
musl uses the legacy `open`(2)/`stat`(4)/`lstat`(6); duplicated descriptors are
identified by naming every injected `memfd` uniquely and reading it back from
`/proc/<pid>/fd`, which survives `dup`, `fork` and `exec` for free; and not
trapping `close` costs correctness rather than just memory, since fd numbers
are reused and a stale mapping shadowed a live one until eviction was added.

Portable to musl: `ioctl` goes through a raw syscall because glibc's prototype
takes `unsigned long` and musl's takes `int`, and the `SECCOMP_IOCTL_*` numbers
have the high bit set. Reads of tracee memory are clamped to the end of the
current page, since `process_vm_readv` fails the whole request if any part is
unmapped — an ASLR-dependent flake when a path was the env string at the top of
the stack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Builds all three mechanisms and the three probe linkages, then runs one
identical workload through each. Needs a Zig toolchain and unprivileged user
namespaces; no root anywhere.

Distinguishes "fail" from "WRONG DATA" deliberately: one of the three
mechanisms was briefly capable of returning a clean, confident, wrong answer,
and a harness that only checked exit status would have called it a pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`.agents/proot-plan.md` is the measured comparison and the recommendation:
drop the `LD_PRELOAD` approach, land the user-namespace one for the common
case, and pursue the seccomp one for the case that motivated the question —
because the environments where "no kernel mount" is actually wanted are
exactly the ones that withhold `/dev/fuse`, and there the FUSE route cannot be
made to work from inside by any means.

`.agents/environment.md` gains what was verified on this host while spiking:
there is no `fusermount3` here at all, unprivileged user namespaces are the way
around that, Node can never enter one itself, and seccomp user notification
works unprivileged even under the filter the shell already runs beneath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mountx Ready Ready Preview, Comment Jul 29, 2026 12:47pm

Request Review

pi0 and others added 6 commits July 29, 2026 11:59
`exec(driver, argv)` runs a command with a driver grafted onto its
filesystem view: the command and everything it spawns see the driver at
`$MOUNTX_ROOT`, and nothing else on the host does.

`probeExec()` publishes what each mechanism can do here and why not, in
the shape `probeTransports()` does, and `exec()` takes the first usable
one in preference order — the user namespace where the kernel's FUSE is
usable, the seccomp supervisor otherwise, which is the case that
motivated the question in the first place: a container that withholds
`/dev/fuse` withholds it from a namespace root too (`mknod` answers
EPERM). Both arrive through `await import()`, so choosing one loads
neither the other's codec nor the other's session, and the result is the
mechanism's own result object with a `mechanism` discriminant defined on
it — tagged, not wrapped. No fallback after a failure and no probe when a
mechanism is named, both for `src/auto.ts`'s reasons.

`src/exec/probe.ts` is import-light like `src/nfs/probe.ts` and
`src/9p/probe.ts` — `node:fs` and nothing else — so asking never pulls in
a codec. It names the causes a caller can act on rather than reporting
one errno: no device node, a device that will not open (and why the
namespace does not rescue that), each distribution's idiom for disabling
unprivileged user namespaces, a missing `unshare`, an architecture the
seccomp filter was not written for, and a supervisor that is not built.

Deliberately outside `mountx/auto`, whose contract is a mountpoint this
produces none of — the same line `mountx/s3` sits on.

`execUserns()` comes up to shipping quality with it: the host verdict up
front instead of an ENOENT from three processes away, `cwdRefusal()` for
the witnessed spawn deadlock, a mountpoint that is created and checked
here, and a relay failure that reaches the caller as an error rather than
masquerading as the command's own exit status. `default_permissions` is
no longer the default, because the kernel checking a driver's uid against
a namespace that maps exactly one is what makes every write fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`spike-a.ts` and `spike-b.ts` become `demo-userns.ts` and
`demo-preload.ts`, beside the `demo-driver.ts` they already shared: the
spike numbering was scaffolding, and one of these two is now the shipping
mechanism behind `mountx/exec` while the other is the rejected one. Both
stay test benches rather than entry points, and both keep calling their
mechanism by name — the value of `test/exec/compare.sh`'s matrix is that
each column is one named mechanism and not whatever the picker would have
chosen.

`preload.ts` gains the verdict in its own header, so the file says what
it is without a trip to `.agents/proot-plan.md`: rejected, kept as the
evidence, reachable from its runner and the comparison harness and from
nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`strategy.test.ts` is Tier 0 and answers for darwin, win32 and arm64 from
any host through the `platform`/`arch`/`supervisor` overrides, the way
`test/auto.test.ts` does: the preference order, the reason each mechanism
is ruled out, the refusal to answer for Linux from a host that cannot
read Linux's files, and the named-mechanism paths that must not consult
the probe. `cwdRefusal()` is covered here too — checking it any other way
costs a hung process.

`userns.test.ts` is Tier 2 and needs no root, only `/dev/fuse`, user
namespaces and `unshare`; it skips itself without them and, like the FUSE
rootless suite, unless the threadpool has been raised. It does not
re-test the filesystem — what the command sees is FUSE, which has its own
conformance column — only what is different: the driver at
`$MOUNTX_ROOT`, a write that lands in the driver, the mount staying out
of the host's mount table, the command's own status surviving, and a
relay failure arriving as an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It goes in Transports rather than the guide for the reason S3 already
did: a transport is everything between the driver and whatever is going
to read it, and "whatever is going to read it" being one command rather
than the machine does not change what the page has to explain. The
section intro now leads with five ways rather than four, and the two that
produce no mountpoint are grouped as such.

Guide-level prose first — what it is for, what the two mechanisms are and
which host facts decide between them, `$MOUNTX_ROOT` and the one rule
about `cwd` — then the full export surface. The reference index gains the
tenth subpath and a row in the platform table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The code map gains an `Exec` section at the density the rest of it keeps —
what each file is, and the reasons behind the choices somebody would
otherwise re-litigate: why the probe opens `/dev/fuse` instead of stat-ing
it and why it does so before reading `/proc/filesystems`, why the
namespace is entered by a child, why `default_permissions` is not a
default, and why `LD_PRELOAD` is still in the tree while being reachable
from nothing that ships. The tests section gains `test/exec/`, and the
`test:rootless` line gains the one mount column that needs neither root
nor a `fusermount3`.

`.agents/proot-plan.md` was the spike write-up and is now the record
behind a shipped feature, so it says so at the top and marks each
mechanism's fate in its own table; its measurements are untouched.
`default_permissions` is added to spike A's list of witnessed costs — it
was not found during the spike because the spike's workload never wrote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`execUserns()` spawns the relay rather than importing it, so it has to be
a file with a path — which is why it is a build entry of its own. What
does not survive is the assumption that it stays *this* module's sibling:
`src/exec/index.ts` reaches `userns.ts` through `await import()`, and
obuild answers a dynamic import with a chunk, so the built module is
`dist/_chunks/userns.mjs` and the relay is one directory over in
`dist/exec/`. Caught by running the built package rather than the source,
which is the only way this shows up at all.

Both layouts are now checked, and a third would announce itself as the
named error rather than as an ENOENT from `spawn`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pi0 and others added 9 commits July 29, 2026 12:14
The spike's client could walk, open, read and list — enough to prove a
traced process can be served, and nothing a program that writes can use.
This adds `Tsetattr`, `Tmkdir`, `Tunlinkat`, `Trenameat`, `Tsymlink`,
`Treadlink`, `Tlink`, `Tmknod`, `Tstatfs` and `Tfsync`, every one of them
transcribed field for field from `src/9p/protocol.ts` the way the rest of
the file already is.

Two things the walk itself needed. Paths are now walked in
`P9_MAXWELEM`-sized steps *in place* rather than refused past the
sixteenth component, so a deep path costs one fid rather than an error;
and `walkOnce` exposes a single step, which is what a supervisor
resolving symlinks component by component needs — `Twalk` stops at a
symlink and a server that resolved links itself would have no way to
answer `lstat`.

Fid numbers are recycled through a fixed free list, and only after a
`Tclunk` the server acknowledged: a counter that only goes up is fine for
a spike and wrong for a supervisor that outlives a `cp -r`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spike answered `openat` by slurping the whole file over 9P into a
`memfd` and injecting that. It bought native `read`/`lseek`/`mmap` for
free, and it had one failure mode that is the worst this project has:
because a `memfd` is always writable and `write` was not trapped, a
program's writes landed in a private in-memory copy and vanished.
`dd conv=notrunc` reported success and changed nothing; `rm -f` reported
success and the file was still there. Silent data loss.

So the copy is gone. The injected descriptor is now an *empty*
placeholder whose only jobs are to be a number the kernel agrees exists
and to carry an identity through `/proc/<pid>/fd/<n>` that survives
`dup`, `fork` and `exec` — and `read`, `write`, `lseek`, `readv`,
`writev` and the `p*` forms are trapped and answered from the driver
against a per-open-file-description offset. `dup` shares that offset,
because the offset lives on the description and descriptors are
reference counts on it, which is what POSIX says and what the
`LD_PRELOAD` spike could not manage at all.

**`close` is trapped now**, which needed the supervisor to stop sharing a
filter with its tracee: the child installs the filter and hands the
listener back over `SCM_RIGHTS`, the shape `native/src/main.zig` already
implements for `fusermount3`. A supervisor with no filter of its own may
call anything, so it may also trap `close` — and without that, descriptor
numbers are reused immediately and a stale mapping shadows a live one.

What else now works, none of which the spike attempted: `creat`,
`mkdir`, `unlink`, `rmdir`, `rename`, `symlink`, `readlink`, `link`,
`mknod`, `chmod`, `chown`, `utimensat` and the three legacy time calls,
`truncate`, `access`, `statfs`, `fsync`, and a virtual working directory
that `chdir`, `fchdir` and `getcwd` agree on, so `cd $MOUNTX_ROOT && ls`
behaves. Symlinks are resolved here, component by component, exactly as
`test/9p/client.ts` does it for the conformance column.

Four things are refused rather than faked, because each of them would
otherwise be a confident wrong answer against an empty placeholder:
`mmap` of a file on the tree (`ENODEV`), `sendfile`/`splice` (`EINVAL`),
`copy_file_range` (`EXDEV`) and `fallocate` (`ENOTSUP`) — the last three
with the errno that makes callers fall back to `read`/`write`, which are
answered properly. Extended attributes answer `ENOTSUP`, which is what
the 9P transport answers anyway. `execve` of a binary living on the tree
stays out of scope and is recorded as such.

Every handler returns a reply value the loop sends, so "answered twice"
and "never answered" are unrepresentable rather than merely avoided; the
tables grow instead of silently dropping the 257th descriptor; and
`seccomp_notif.pid` is finally read as what it is — a *thread* id — with
descriptors, cwd and umask keyed on the thread group behind it, which is
what a multi-threaded tracee needs.

One bug worth naming because it was invisible: memoising the `/proc`
identity walk under the new descriptor number looked like an obvious win
and made `echo hi > $ROOT/file` claim the shell's standard output for the
rest of the run, since the `dup2` that restores it cannot be seen as a
replacement. The walk is no longer cached, and `dup2`/`dup3`/
`close_range` are trapped for the bindings that are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every transport in this repository carries a column of the matrix, and
this is the seccomp supervisor's: `test/conformance.ts` unmodified, with
the driver at the far end of a syscall boundary.

It needed a shape the other columns did not. A transport is normally
reached through a client library — `test/9p/client.ts` speaks 9P,
`test/nfs/v4/client.ts` speaks COMPOUND — but this supervisor has no
client, because its interface *is* the syscall ABI. The only way to drive
it is to be a process making syscalls. So `seccomp-helper.ts` is a
filesystem REPL that runs under the filter and does what it is told with
`node:fs/promises`, and `seccomp-client.ts` is an `FsDriver` over the
pipe to it. A single `fs.stat()` in the suite becomes a `statx(2)` in a
traced process, a notification, a 9P walk, an `Rgetattr` and a
`struct statx` written back into that process's memory, with nothing
short-circuited anywhere. 65 of 66 cases pass; the skip is the root-only
`lchown` one every column skips.

Using `node:fs/promises` rather than the sync API is deliberate twice
over: it is exactly what the loopback column runs against, so a
disagreement is the supervisor's rather than the API's, and it puts every
request on a libuv threadpool thread — so the notifications arrive from a
thread that is not the one that started the process, which is the
multi-threaded case the tables have to key on a thread *group* to
survive.

`seccomp-run.test.ts` is the narrower, more literal file: it runs the
shell, `dd`, `mv` and a static no-libc binary, and asserts on the
**driver** afterwards rather than on what the command printed — because
"the command exited 0" is precisely what the old silent-data-loss failure
looked like. It also covers eight concurrent tracee processes, a
descriptor a child inherited after the parent closed it, a path deeper
than one `Twalk` can carry, and four hundred sequential opens.

`execSeccomp` grows two optional fields, `stdio` and `onSpawn`, because a
command that is being *driven* needs its streams and its handle while the
promise is still outstanding. Nothing existing changed shape.

All three files skip cleanly without a Zig toolchain or off x86-64 Linux,
and none of them needs root — which is the property the whole mechanism
exists for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`walkTo` clunks its own fid before recursing into a followed symlink, and
an `errdefer` then clunked it a *second* time on the way out of a
resolution that failed. The server answers `EBADF` for a fid it has
already forgotten, and that errno overwrote the `ENOENT` the caller was
about to report — so a dangling symlink came back as `EBADF`, a symlink
loop came back as `EBADF` instead of `ELOOP`, and an exclusive open of a
link came back as `EBADF` instead of `EEXIST`.

Cleanup is explicit now rather than deferred, which is the only shape
that can express "this path already released it".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The harness compares three interception mechanisms on one workload, and
until now that workload only read. Write-back is the axis that actually
separates them, and it is the one where a failure is silent: the seccomp
supervisor answered every write into an injected `memfd` that was then
discarded, so `dd conv=notrunc` and `rm -f` both reported success and
changed nothing.

Four commands, four expected words. Anything else printed there is data
being lost quietly, which is worse than failing.

The entry point's own header stops calling itself a spike while it is
here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A `cd` out of the tree that *fails* no longer clears the virtual working
directory. A shell whose `cd /nowhere` failed is exactly where it was,
and forgetting on the attempt meant every relative path afterwards
resolved against the real working directory instead — somewhere else
entirely. The supervisor carries no filter of its own, so it can simply
ask the kernel whether the target is reachable before believing the
process left.

`mknod`'s `dev_t` is unpacked with glibc's own encoding
(`bits/sysmacros.h`) rather than a mask that dropped a nibble of the
minor number and all of the high major bits.

And the thread-id cache is pruned on `exit_group`. It is the one table
keyed on something that dies with the process rather than being reused,
so it is also the one that would otherwise grow for the whole run of a
command that spawns steadily.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`zig fmt` over the three new files, plus two things that were describing
something that is no longer true: the placeholder descriptor's
supervisor-side copy is closed the moment it is injected (identity comes
from `/proc`, not from holding it open), and there is no way to destroy a
file record at all — its fid is released when nothing names it, and the
record stays so that a descriptor resolved through `/proc` can re-open
it rather than being answered from an empty placeholder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`spike-c.ts` was held back from the rename while the seccomp supervisor was
being implemented in parallel, to keep two concurrent efforts off the same
file. It is `demo-seccomp.ts` now, with the same header the other two got, and
`test/exec/compare.sh` selects all three columns by mechanism name rather than
by a spike letter — which also fixes the harness, whose A and B columns had
been pointing at paths the earlier rename moved.

`.agents/proot-plan.md`'s account of spike C's costs is brought up to date: the
`memfd` slurp and the read-only limitation are both closed, and the read-only
one is worth keeping visible rather than deleting, because it did not fail
cleanly — writes landed in an in-memory copy and vanished, which is the same
silent-wrong-answer class this document rejects spike B for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pi0x pi0x changed the title spike: proot-style exec() — three interception mechanisms, measured feat(exec): mountx/exec — run a command against a driver, FUSE-in-a-namespace or seccomp Jul 29, 2026
@pi0 pi0 changed the title feat(exec): mountx/exec — run a command against a driver, FUSE-in-a-namespace or seccomp feat: mountx/exec Jul 29, 2026
@pi0
pi0 marked this pull request as ready for review July 29, 2026 12:38
pi0 and others added 2 commits July 29, 2026 12:45
`preload.ts`, `preload/shim.zig` and `demo-preload.ts` are gone. They were kept
for a while as the evidence for their own rejection, which is a job prose does
better than code: `.agents/proot-plan.md` still carries the measurements, and
they are what justify the shape of the two mechanisms that remain — a boundary
the kernel defines rather than one glibc does.

The 9P client they shared with the supervisor stays, and moves to
`src/exec/seccomp/p9.zig` now that `preload/` no longer names anything. It is
still the reason the supervisor contains no filesystem.

`test/exec/compare.sh` loses its third column and its shim build; both probe
linkages and the write-back row are unchanged, and A and C still agree on every
one of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pi0x

pi0x commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #10, which ships the user-namespace mechanism (A) on a branch cut fresh from main.

Closing rather than merging, deliberately: this branch is where the seccomp user-notification supervisor (mechanism C) lives, and it is worth keeping reachable. Nothing of it is on #10.

What is preserved here, at feat/proot:

  • src/exec/seccomp/ — the supervisor: streamed I/O per open-file-description with a dup-shared offset, close trapped via the SCM_RIGHTS split, namespace mutation, metadata, a virtual cwd, and descriptor identity recovered from /proc/<pid>/fd so dup/fork/exec survive for free. Static musl build works, which is what makes it run on bare Alpine.
  • src/exec/seccomp/p9.zig — the 9P2000.L client it speaks, and the reason it contains no filesystem of its own.
  • test/exec/seccomp-conformance.test.ts — the full conformance column through a traced process, 65 of 66 cases (the one skip is the root-only lchown every column skips).
  • test/exec/compare.sh plus probe.c/probe-raw.c — the harness that measured both mechanisms across three linkages and a write-back row.
  • .agents/proot-plan.md — the three-way comparison, including the autopsy of a rejected LD_PRELOAD interposer.

Why C is not shipping now: the probe prefers A wherever the kernel's FUSE is usable, which is nearly everywhere, and C is only better when /dev/fuse is withheld — a locked-down container, or a kernel without CONFIG_FUSE_FS. It is also still serialized (one notification at a time, one 9P request in flight), has no mmap of a file on the tree, and cannot execve a binary living there. #10 keeps the API seam — a mechanism discriminant and a per-mechanism probe verdict — so it can return without a breaking change.

One thing found while porting and worth carrying forward: a child could not unlink, rmdir, rename, link or truncate anything it had not itself created, because FUSE wire ids are named in the mount namespace's id space and unshare -U -r maps exactly one. Fixed in #10 by an opt-in FuseSession.idmap. It is not a defect in this branch's seccomp mechanism, which never had the problem — it does not mount.

@pi0x pi0x closed this Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants