An ARM64-focused PRoot fork for Android/Termux containers. neoproot targets developers who run Debian/Ubuntu userspaces on phones and tablets, especially Node.js, pnpm, TypeScript, nvim, bwrap, and other syscall-heavy workflows.
GitHub Release assets are ARM64 Linux (glibc) CI builds. Termux needs a local bionic build; do not install those assets as $PREFIX/bin/neoproot.
curl -fsSL https://github.com/Raymer8639/neoproot/releases/latest/download/install.sh | shThe script detects the platform and does the right thing: on Termux it installs the build dependencies, builds the bionic binary from the tagged source, backs up the previous binary and installs the new one into $PREFIX/bin; on ARM64 Linux it downloads the release binary, verifies it against SHA256SUMS, and installs it into your PATH. It never touches a running container's rootfs. Read scripts/install.sh before piping it to a shell if you prefer; sh scripts/install.sh --help lists --version, --prefix and --portable.
Always build on the Termux host. Linux CI / Release files will not run correctly as the Termux Android binary.
pkg install clang make llvm binutils pkg-config libtalloc
git clone https://github.com/Raymer8639/neoproot.git
cd neoproot
sh install.sh
# Or build with Meson (GNUmakefile remains the reference implementation):
# Install meson+ninja with your package manager first.
CC=clang CXX=clang++ meson setup builddir
meson compile -C builddir
meson install -C builddirThe installer places neoproot in $PREFIX/bin.
On ARM64 Linux (not Termux), download neoproot or the lower-instruction-set neoproot-portable from Releases, verify its SHA256, and install it in your PATH.
- Automatic Termux host setup: wake-lock, file-descriptor limits, and
LD_*cleanup are handled by theneoprootlauncher. - link2symlink hard-link emulation works with pnpm and TypeScript/tsgo workflows that probe real paths through
/proc/<pid>/fd/<fd>. - High-frequency path operations avoid the fork's removed translation-thread-pool overhead, keeping nvim and package-manager workflows responsive.
- Optional
--seccomp-notify(Linux 5.0+) emulatesnewfstatatvia seccomp USER_NOTIF so directory metadata scans skip a ptrace stop. Kernel 4+ without the flag is unchanged. - Optional
--stat-shim=<guest-lib>preloads a guest-ABILD_PRELOADlibrary that resolvesstat/lstat/statxin-process (bind translation and fake_id0), keeping link2symlink results exact through a flag-gated USER_NOTIF fallback. Off by default. See stat-shim/README.md. - Built-in high-priority scheduling (
setpriority(-20)) improves CPU availability without root access. - Fixes include Chinese VNC exit hangs, logout/background-switch issues, and the misleading
signal 11exit warning. - The codebase uses a lean C23/C++23 implementation tuned for ARMv8.2, trading generic architecture coverage for ARM64 performance.
- 64-bit ARM Linux (
aarch64/ ARMv8.2+), with Android/Termux as the primary target. - Termux or an equivalent Android Linux environment, or an ARM64 Linux host.
- The optimized build is intentionally not supported on x86_64. Use an ARM64 machine for the published binaries.
neoproot -0 -r /data/data/com.termux/files/home/rootfs \
-b /dev -b /proc -b /sys -b /sdcard \
/usr/bin/env -i HOME=/root TERM=${TERM} PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
/bin/bash --loginThe command-line interface follows official PRoot conventions. You do not need to manually unset LD_PRELOAD, LD_LIBRARY_PATH, or LD_BIND_NOW; the launcher handles those variables before entering the guest.
neoproot has two execution backends. The default PRoot tracer -- selected when
--backend is omitted, or explicitly with --backend=fast -- translates paths
and user/group IDs with ptrace (plus optional seccomp USER_NOTIF) and needs no
root. The UML backend is the optional second execution backend: --backend=um
boots a separate UML guest kernel through the neoproot-um supervisor, so the
command runs on a real Linux kernel instead of behind syscall translation. It
must be started from an untraced Termux host process, not from inside an
existing PRoot container or sandbox.
The UML backend is meant to be used with an ext4 image + COW file; the default in the launcher and in the documentation examples. Measured on the phone this was developed on, an ext4 root is faster than a hostfs root for sequential IO -- 295 vs 242 MB/s for a 64 MiB write, 1.1 GB/s vs 672 MB/s for a 64 MiB read -- because hostfs pays a user-space round trip per operation while the ext4 block layer batches inside the guest. Metadata-heavy work is the other way (300 renames: 0.19 s hostfs vs 0.26 s ext4) but the absolute difference is a few tens of milliseconds.
The decisive reason is correctness, not speed: a hostfs root passes host
ownership through unchanged, so files appear in the guest with the Termux app
uid and chown cannot succeed (the host process has no CAP_CHOWN). That
makes apk add print Failed to set ownership ... Operation not permitted for
every file. An ext4 root has real ownership inside the guest, so package
management works: apk add sudo completes with OK: 10 MiB in 17 packages.
A hostfs root remains available for the UML backend with --hostfs (the
launcher's flag), and is still what the PRoot-based backends use, because path
translation needs a directory tree rather than a filesystem image.
-r/--rootfs accepts either a host directory or an ext4 image. A directory is
exported to the guest as UML hostfs. A hostfs root is not isolated: file
ownership is the host's, passed through unchanged, so files owned by the Termux
app uid appear with that uid inside the guest and chown cannot succeed -- the
host process has no CAP_CHOWN, and changing a file's owner to another uid is
exactly what that capability is for. Anything that only installs files works
(writing, creating, deleting, chmod); anything that must re-own them does not,
which includes apk add (it reports Failed to set ownership ... Operation not permitted for each file and still exits having written them). Use an ext4 image
when guest-side ownership matters. An ext4 image is mounted as the guest root
with a per-run temporary COW file that is deleted when the run ends; the base
image is never written. --cow-file=PATH keeps the COW file at PATH instead
(PATH must be absolute): it is created when missing, reused by the next run --
so that run sees what the previous one wrote -- and kept after the run.
--cow-file and --readonly are mutually exclusive; with --readonly an ext4
image is opened directly without a COW file. A COW file is tied to the image it
was created for (its header records the image's size and mtime), and a run
refuses to start when the file was made for a different image. The UML kernel
and stub_exe can be supplied with command-line options to neoproot-um, or
through these environment variables:
export NEOPROOT_UM_KERNEL=/path/to/linux-bionic
export NEOPROOT_UM_STUB=/path/to/stub_exe
neoproot --backend=um -r /path/to/rootfs -- /bin/shBuild the kernel and stub with
build-bionic.sh
from linux-um-arm64, and keep
them matched with the um-init, umnet and passt they are used with; a
mismatched set is not supported. passt is the companion built from
passt-android. The optional
config/smp.config and config/docker.config fragments in that tree build
the SMP/container kernel profile used by --cpus.
The UML kernel's default userspace mode is ptrace. --seccomp=auto lets it
use the kernel's seccomp mode when the host allows it, and --seccomp=on
requires that mode; both are opt-in. In two device A/B runs on the same image,
kernel and probe, a syscall-heavy probe took 43-61 s with ptrace and 4.2-5.1 s
with seccomp: getpid fell from 158-221 to 12.5-13.0 us, open+close
from 501-503 to 14-50 us, and read from 209-222 to 5.9-13.7 us. Boot time
for a one-shot command moved much less (827 to 751 ms), because kernel startup
dominates that case. --quiet
passes quiet loglevel=1 to the kernel and suppresses the boot log; it does
not change the syscall mode.
seccomp mode is not a security boundary. The kernel documentation states
that its filter is not restrictive enough: guest userspace can read and write
all physical memory and can trick the stub into disabling the timer signal.
Use it only with a guest you trust; the default stays ptrace.
An SMP-enabled kernel can run more than one guest CPU: --cpus=N (1-64)
passes ncpus=N on the kernel command line, with a default of 1 so upgrading
the kernel does not silently change the CPU count of existing runs. Each guest
CPU is a host thread, and the kernel in use must have been built with
CONFIG_SMP; a uniprocessor kernel ignores the request. --cpus greater than
1 requires --seccomp=auto or --seccomp=on: ptrace userspace can drive only
one CPU thread, so the supervisor refuses the request instead of letting the
kernel clamp it after the caller has sized the workload for several CPUs.
--rootflags=OPTS passes extra ext4 mount options on the kernel command line,
for example commit=60,data=writeback. It is accepted only for an ext4 image;
--readonly always keeps noload first (replaying the journal would write the
base image) and appends the caller's options after it.
Hostfs exposes the selected host directory directly to the guest; it is not a
security isolation boundary. Bind sources already inside that root can be
mapped with -b host:guest[:ro]; sources outside it, controlled targets such
as /proc, /sys, and /dev, and unsafe targets are rejected.
Ext4 mode accepts the same -b host:guest[:ro] syntax, with host paths as
sources: an ext4 guest has its own root, so there is no "inside the rootfs" to
restrict a source to. The guest init mounts each source -- itself a hostfs
mount, made read-only for :ro -- and a directory target must exist, or be
creatable, inside the guest; with --readonly the image cannot be modified, so
a target there must already exist. Ext4 mode requires /um-init from the
matching UM harness. The supervisor passes the session-v1 protocol
parameters after mounting the guest's proc filesystem; the init script mounts
the temporary hostfs session at /run/neoproot and returns the command status
through that mount.
Binds need an init that knows how to mount them, and that is carried as an explicit protocol feature rather than assumed:
- The supervisor writes the requested binds to
/run/neoproot/binds, one record per line with tab-separated fields:source<TAB>target<TAB>dir|file<TAB>ro|rw<TAB>name.sourceis a hostfs mount option (a path relative to the kernel'shostfs=root), andnameis the file name for a file bind and-for a directory bind. - An init that implements them writes
/run/neoproot/init-featuresbefore it runs the command, withneoproot-init-protocol=2andfeature=binds. - When binds were requested and that declaration is missing, the supervisor
refuses to report success (
neoproot-um: the guest /um-init does not support binds), and the generated wrapper refuses to run the command at all. A guest-side mount failure is reported through/run/neoproot/protocol-errorand printed asneoproot-um: guest init reported: ....
On an ext4 root, -b therefore requires an init implementing init protocol
2 with the binds feature. An init that predates it -- including a legacy
um-init -- makes the run fail with neoproot-um: the guest /um-init does not support binds; the command is not run with its binds silently missing.
tests/um-init-ext4.sh is the reference implementation
of that init, and tests/stability-um-backend.sh
installs it into the images it builds. The UM harness's own um-init needs the
same two additions before it can be used with -b on an ext4 root.
--net gives the guest a real IPv4 network through umnet + passt: DHCP,
DNS, ICMP and TCP work, and the guest's exit status still comes back through
the session protocol. umnet and passt are taken from $PATH, or from
NEOPROOT_UM_NET / NEOPROOT_UM_PASST. --net-addr= and
--net-gateway= override the guest address and gateway (default
10.0.2.15/24 via 10.0.2.2). The guest is told a nameserver only when
--net-dns=<address> is given with --net; by default umnet reads the
host's own resolver, and --net-dns=none announces none.
--net builds the link, not the guest's configuration of it: in UML the
guest owns its interfaces, and the DHCP server passt runs only answers a
client that asks. So --net -- /bin/sh alone comes up with vec0 down, no
address and no route. Add --net-auto to have the guest side configured for
you -- udhcpc against the DHCP server first, then a static fallback using
--net-addr=/--net-gateway= if there is no client or no lease. It never
fails the run: a guest without a working network still starts its shell.
--net-auto implies --net and works for both root kinds: the script that
configures the guest is generated by the supervisor and run from the guest's
init either way.
neoproot --backend=um -r /path/to/rootfs --net-auto -- /bin/shThe guest's loopback is brought up for every run, so 127.0.0.1 works without
any extra setup; only the rest of the network depends on --net.
IPv6 is available with --net-ipv6, which tells umnet to start its second,
IPv6-only passt helper:
neoproot --backend=um -r /path/to/rootfs --net --net-ipv6 -- /bin/sh--net-ipv6-addr=ADDR[/PLEN] and --net-ipv6-gw=ADDR override the guest's
IPv6 address and gateway (default fd00::2/64 and fe80::1); without them
umnet's own defaults are used, so neoproot passes no value it would have to
guess. --net-ipv6-dns=ADDR announces an IPv6 resolver, --net-ipv6-dns=none
announces none, and by default umnet reads the host's resolver. The IPv6
options are a mode of --net, not a network of their own: without --net the
run fails with neoproot-um: --net-ipv6 options require --net rather than
starting a guest that was promised a stack it does not get.
IPv6 is a second helper rather than a dual-stack one, so that a passt
without prepatched IPv6 support -- which loses its IPv6 half on Android
because it opens NETLINK_ROUTE -- leaves IPv4 working. That helper needs a
UM harness built with the support; with an older one the run continues with
IPv4 only and says so.
The guest console is attached to the caller's terminal, and terminal resizes (SIGWINCH) are forwarded to the guest, so a full-screen tool started this way sees the right window size.
The terminal is put into raw mode for the duration of the run and restored
afterwards. Without that, the guest's own line editor asks for the cursor
position (ESC [ 6 n), the answer is echoed back and ^[[r;cR appears on
screen; typed input is echoed twice; and control characters are interpreted
by the host terminal before the guest sees them. Pipes and redirected files
are left untouched.
The guest command runs under a fresh session (setsid -c) for both root kinds,
so the console is its controlling terminal: /dev/tty opens, login shells stop
printing can't access tty; job control turned off, and Ctrl-C / Ctrl-Z reach
the guest's foreground process.
--timeout=SECONDS bounds a run (default 120). The supervisor catches
SIGINT/SIGTERM/SIGHUP/SIGQUIT, signals the UML process group with them, cleans
that group up, and returns a meaningful status:
- the guest's own exit status is returned unchanged;
- a timed-out run returns
124; - a forwarded signal returns
128 + signal(143for SIGTERM); - a guest session/init failure returns
125, and failure to enter the requested working directory returns126.
A killed or panicking run no longer leaves a ~/.uml/<umid> runtime directory
behind: the supervisor removes the directory once the kernel it names is gone.
- Hostfs exposes host paths directly and is not a security isolation boundary.
- Start UML mode from an untraced host process; it cannot be nested inside PRoot or another sandbox.
- The default guest kernel profile is uniprocessor. A separate SMP kernel
(
CONFIG_SMP) and--cpus=Nare required for more than one CPU;--cpusgreater than 1 also requires seccomp. - The guest runs aarch64 userland only: the kernel is built with
CONFIG_COMPAToff, so 32-bit ARM binaries do not run. - Kernel, stub and
umnetmust come from matching linux-um-arm64 builds; the guest/um-initmust implement the session-v1/init-protocol-2 contract (reference:tests/um-init-ext4.sh).passt-bionicis the companion built from passt-android. - Ext4 roots have been exercised at 64 MB, 128 MB and 256 MB; other sizes are untested.
The pre-SMP on-device stability matrix is 18/18 PASS, covering hostfs with
and without --net, guest exit status 7 and 125, ext4 with and without
--net, ext4 --readonly, persistent and temporary COW files, read-write
and read-only binds, refusal of a legacy init, a missing bind target,
--timeout, and SIGTERM. See tests/stability-um-backend.sh.
The 2026-10-24 SMP/Docker add-on was verified separately: 2 and 4 CPUs came
up, cgroup2 exposed cpu io memory pids, unshare -p -f and a USER_NS probe
succeeded, --rootflags=commit=60,data=writeback appeared in /proc/mounts,
and the --seccomp=auto/--quiet argv tests passed. The new argv/CLI tests
are in tests/test-um-supervisor-seccomp-argv.sh
and tests/test-um-supervisor-optimizations-argv.sh.
When --backend is omitted, neoproot uses the default PRoot backend;
--backend=fast selects that same path explicitly.
--backend=auto prints its choice to stderr and decides in two steps.
First, the prerequisites: it selects the UML backend only when a kernel and
a stub are configured -- through --kernel/--stub (forwarded to the UML
supervisor, or given to neoproot-um directly) or a non-empty
NEOPROOT_UM_KERNEL / NEOPROOT_UM_STUB -- and a rootfs is given with
-r/--rootfs. Otherwise it uses the default PRoot path. It never probes
PATH or the filesystem for a kernel: the optional backend only switches
itself on when the caller has set it up. This is an invariant, not a
heuristic: a command that needs a kernel cannot make auto start one that is
not configured.
The rootfs decides as well, and this part is not a speed preference: PRoot
translates guest paths into host paths, so it needs a directory, while an ext4
image has no host paths and only the UML backend can boot it. When -r names
an image (or --cow-file is given) auto takes the UML backend whatever the
command is, and --auto-um-commands cannot override it:
neoproot: --backend=auto selected the um backend (the rootfs is a filesystem image, which only the um backend can use)
The other backends explain the same thing instead of failing on the image as if
it were a directory (or rejecting --cow-file as an unknown option):
neoproot: the rootfs is a filesystem image; only the um backend can use it.
neoproot: use --backend=um (or --backend=auto to select it), or pass a directory rootfs.
Second, within that limit, the command decides. PRoot stays the default, because it is the fast path and most work is path translation; UML is chosen only for a command that needs a real kernel -- a container runtime's control plane, a mount, a namespace, a block device, a firewall, a packet filter:
docker dockerd podman podman-remote nerdctl containerd ctr runc crun
systemd-nspawn machinectl mount umount losetup swapon swapoff
unshare nsenter ip iptables nft bpftool
The default set is a list of names, not a guess. To replace it, use
--auto-um-commands=LIST (comma-separated basenames) or the equivalent
NEOPROOT_UM_AUTO_COMMANDS environment variable; the option wins when both
are given. An empty value means the default set, and none means "never
choose a backend by command":
neoproot --backend=auto --auto-um-commands=none -r /rootfs -- docker
NEOPROOT_UM_AUTO_COMMANDS=mycmd,other neoproot --backend=auto -r /rootfs -- mycmdThe command is the basename at the position where the guest command begins,
so /usr/bin/docker and docker are the same name. Simple wrappers are
looked through -- env, sudo, command, exec, nohup, time and
stdbuf -- including their VAR=value arguments and the options that take a
separate value, so env FOO=1 docker is judged as docker.
The boundary is deliberate and worth knowing: sh -c 'docker run' is sh
and stays on the default backend, because auto reads the argument vector and
does not parse guest shell code. A value packed into a combined short option
of a wrapper (sudo -Eu root docker) is skipped as one argument, so that
wrapper is not looked through. Everything else keeps working as before:
--backend=fast, --backend=um and the default ignore both the list and the
command.
Every outcome is reported, so auto is never silent about a downgrade:
neoproot: --backend=auto selected the um backend (command 'docker' needs a real kernel)
neoproot: --backend=auto selected the um backend (the rootfs is a filesystem image, which only the um backend can use)
neoproot: --backend=auto selected the default backend
neoproot: --backend=auto would use the um backend for 'docker', but no kernel/stub/rootfs is configured; using the default backend
neoproot: --backend=auto would use the um backend because the rootfs is a filesystem image, but no kernel/stub is configured; using the default backend
The -- separator means the same thing on every backend:
neoproot -r /path/to/rootfs -- /bin/shThe CLI consumes the separator once and does not forward it, so a backend that
does not know it never sees it, and the same command line can be reused with a
different --backend -- which is what auto needs. The UML dispatcher
restores the delimiter for the UML supervisor, whose own syntax requires it.
Omitting -- also works, since the CLI already knows where the guest command
begins.
It is tempting to ask for a mode that "runs normal syscalls through neoproot and sends the rest to UML". That cannot work, for a structural reason: the two backends operate at different levels.
- PRoot is a tracer. The guest process is a host process;
ptrace(and, for selected calls, seccomp USER_NOTIF) stops it at the syscall boundary, and neoproot rewrites paths, IDs and results before the host kernel runs the call. - In UML mode the guest is a real kernel. The guest's syscalls are serviced inside the UML kernel and never appear as the same syscall on the host, so there is no intercepted host syscall to hand to neoproot or to route selectively.
A per-syscall split would therefore need one side to see calls it cannot see, or
a kernel-level interface that does not exist today. The supported granularity is
per invocation, or per process tree: choose a backend when the command starts.
That is exactly what --backend=auto does. Making this finer would require
deeper kernel changes and is outside neoproot.
This is not "not implemented yet": the architecture does not admit it, so it should not be proposed again as a backlog item.
Measured on a Termux aarch64 device, Arch Linux ARM container, identical output in every mode. Stat-heavy work is what a container spends its time on, and that is what neoproot removes:
du -As /usr (whole-tree metadata walk, output 4007438 in all modes)
| Mode | Time | vs plain ptrace |
|---|---|---|
| Plain ptrace path | 33.3 s | — |
--seccomp-notify only |
31.2 s | 1.07x |
--stat-shim + --seccomp-notify |
14.4 s | 2.32x |
Later releases cut the remaining ptrace stops further (fcntl is now traced only for the two fd-duplication commands), taking the same run from 15.7 s to 13.1 s, and 11-12 s on the device after deployment.
Single stat call (µs/op, plain / notify / shim)
| Call | plain | notify | shim |
|---|---|---|---|
| absolute path | 288.7 | 96.6 | 8.1 |
| dirfd-relative | 308.5 | 114.4 | 4.4 |
AT_FDCWD-relative |
203.8 | 90.9 | 92.9 |
raw syscall(SYS_statx) |
324.5 | 106.5 | 10.4 |
AT_FDCWD-relative names keep the tracer in the loop on purpose: the guest's cwd is virtual, so the shim re-issues them through the --seccomp-notify channel instead of guessing. That costs about the same as the plain path (103 vs 111 µs) while staying correct.
Compilation is unaffected (80-file cc -O0 build: 18.2 / 18.5 / 18.3 s across the three modes); the wins are in process startup, parsing and header I/O.
The older sysbench workload, for reference:
| Load | vs official PRoot |
|---|---|
| Low (primes <= 10000) | On par (within noise) |
| Medium-high (50000) | Ahead by 2.9% |
| Extreme (100000) | Ahead by 7.2% |
When reporting a result, include the device/SoC, Android and Termux versions, exact command, baseline version, and repeated measurements. Use the performance report form so results can be compared.
- Default target:
-march=armv8.2-a+fp16+dotprod+lse+rcpc+simd+crc+crypto(override withMARCH=). - Portable releases use
-march=armv8-a -mtune=generic; this relaxes CPU instructions but does not promise libc or kernel compatibility. - Link options include ThinLTO, section garbage collection, identical-code folding, RELRO/NOW, and stripping.
- The build depends on
libtalloc(Termux package:talloc).upxis optional.
- linux-um-arm64 — UML/arm64 kernel,
stub_exe,um-init,umnetand thepasst-bioniccompanion used by the UM backend. Itsconfig/smp.configandconfig/docker.configfragments are the supported way to build the SMP/container kernel profile. - passt-android — the Android/bionic
passtsource used byumnet. - termux/proot — upstream PRoot fixes tracked by the fast backend.
neoproot was renamed from proot-scicat / uproot and continues to track useful fixes from termux/proot. The original implementation is proot-me/proot; the direct fork ancestor is scicat-team/proot-scicat. See CHANGELOG.md for version history and attribution.
Maintainers may use AI tools to assist with research, drafting, coding, or review. AI output is treated as untrusted input: maintainers remain responsible for reviewing and testing changes before merge. See CONTRIBUTING.md for expectations when submitting AI-assisted work.
- Report a bug
- Request a feature
- Ask a usage question
- Read help.md, SUPPORT.md, CONTRIBUTING.md, and SECURITY.md
Versions follow the termux/proot style. Releases before 2026-08-15 used the historical -scicat suffix; v5.7.3 and later releases use the neoproot name without that suffix.
GPLv2. The canonical text is in LICENSE; COPYING carries the original PRoot/CARE copyright notice from upstream.