diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 81068f159..c2d479537 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -240,6 +240,21 @@ jobs:
tar --use-compress-program=unzstd -tf app/src/main/assets/pulseaudio.tzst | grep -c '\.so$'
rm -rf pa-bundle sink-out
+ - name: Cache proot
+ id: proot
+ uses: actions/cache@v4
+ with:
+ path: |
+ app/src/main/jniLibs/arm64-v8a/libproot.so
+ app/src/main/jniLibs/arm64-v8a/libproot-loader.so
+ key: proot-${{ env.NDK_VERSION }}-${{ hashFiles('tools/proot/**') }}
+ - name: Build proot
+ if: steps.proot.outputs.cache-hit != 'true'
+ run: |
+ set -euo pipefail
+ export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
+ tools/proot/build.sh app/src/main/jniLibs/arm64-v8a
+
- name: Check the preloads
run: |
set -euo pipefail
diff --git a/.gitignore b/.gitignore
index 1fa2e10c1..244bb5f33 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,8 @@ captures/
# Built in CI from tools/linuxfs and app/src/main/cpp/fakeinput.cpp, never committed, so the two
# copies cannot drift.
app/src/main/assets/linuxfs/
+app/src/main/jniLibs/arm64-v8a/libproot.so
+app/src/main/jniLibs/arm64-v8a/libproot-loader.so
# Kotlin compiler session files (build-tool state, never source).
.kotlin/
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java
index 838c970b0..b0d9adb89 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java
@@ -58,30 +58,22 @@ public final class LinuxRuntime {
/** Where the runtime carries the host-side proot; see tools/linuxfs/prebuilt/proot/README.md. */
private static final String HOST_DIR = "opt/android-host";
- /**
- * proot, preferred from the installed runtime and falling back to the copy in the apk.
- *
- *
It is an Android binary rather than part of the rootfs - it is what creates the rootfs -
- * but it travels in the runtime tarball so that reinstalling the app cannot replace the one
- * binary everything else depends on, and so a device with no working packaged proot can still
- * run the runtime.
- */
public static File prootBinary(Context context) {
- File shipped = new File(rootDir(context), HOST_DIR + "/proot");
- if (shipped.isFile()) return shipped;
- return new File(context.getApplicationInfo().nativeLibraryDir, "libproot.so");
+ File packaged = new File(context.getApplicationInfo().nativeLibraryDir, "libproot.so");
+ if (packaged.isFile()) return packaged;
+ return new File(rootDir(context), HOST_DIR + "/proot");
}
public static File prootLoader(Context context) {
- File shipped = new File(rootDir(context), HOST_DIR + "/loader");
- if (shipped.isFile()) return shipped;
- return new File(context.getApplicationInfo().nativeLibraryDir, "libproot-loader.so");
+ File packaged = new File(context.getApplicationInfo().nativeLibraryDir, "libproot-loader.so");
+ if (packaged.isFile()) return packaged;
+ return new File(rootDir(context), HOST_DIR + "/loader");
}
/** proot links against libtalloc, which sits beside it; empty when the apk copy is in use. */
public static String prootLibraryPath(Context context) {
File dir = new File(rootDir(context), HOST_DIR);
- return new File(dir, "proot").isFile() ? dir.getPath() : "";
+ return dir.equals(prootBinary(context).getParentFile()) ? dir.getPath() : "";
}
/** The rootfs is present with gamescope and the session script the launcher hands control to. */
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
index ec15c5c2c..5fa6f3c5d 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
@@ -982,7 +982,7 @@ class SessionService : Service() {
private const val EXTRA_AUXILIARY_PID = "auxiliaryPid"
/** Command lines that can only belong to a session of ours. */
private val STRAGGLERS = listOf("bannerlator-session", "gamescope", "Xwayland", "steamrtarm64",
- "steamwebhelper", "linuxfs/opt/android-host/proot", "pulseaudio/libpulseaudio.so")
+ "steamwebhelper", "linuxfs/opt/android-host/proot", "/libproot.so", "pulseaudio/libpulseaudio.so")
/** How long proot gets to run its own cleanup before it is killed outright. */
private const val GRACE_MS = 1200L
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
diff --git a/tools/build_local.sh b/tools/build_local.sh
index cd831a8df..3df403703 100755
--- a/tools/build_local.sh
+++ b/tools/build_local.sh
@@ -135,6 +135,7 @@ fi
export NDK="${sdk_dir}/ndk/${ndk_version}"
sink_output="${staging_dir}/sink-out"
"${repo_root}/tools/aaudio-sink/build.sh" "${pa_source}" "${sink_output}"
+"${repo_root}/tools/proot/build.sh" "${repo_root}/app/src/main/jniLibs/arm64-v8a"
cp -p "${bundle_asset}" "${bundle_backup}"
bundle_dir="${staging_dir}/pulseaudio-bundle"
diff --git a/tools/proot/PATCHES.md b/tools/proot/PATCHES.md
new file mode 100644
index 000000000..64b5857dc
--- /dev/null
+++ b/tools/proot/PATCHES.md
@@ -0,0 +1,21 @@
+# proot patches carried by the app
+
+Built by `tools/proot/build.sh` with the NDK on top of termux/proot `4dba3afb` (Termux's
+5.1.107-70 package, the proot the Linux runtime ships at `opt/android-host/proot`; every
+`file.c:line` site in that binary matches this commit). talloc is linked in statically from
+Samba's release tarball, so the apk carries one self-contained `libproot.so` plus
+`libproot-loader.so`, and `LinuxRuntime` prefers them over the runtime's copy.
+
+Ported from WinNative (`feature/proot-enhancements`, e7af0f24):
+
+- `0001-tracee-lookup-by-pid.patch` - tracees are hashed by pid, so each ptrace stop finds its
+ tracee without walking one list entry per thread, and terminated tracees are swept only after a
+ termination.
+- `0002-canon-resolve-parent-at-once.patch` - a clean absolute guest path at least three
+ directories deep under the rootfs binding alone has its parent opened once with `O_PATH` and
+ taken as canonical when `/proc/self/fd` names the path proot would build, replacing an `lstat`
+ per component. The final component of a call that does not follow it is no longer `lstat`ed.
+ Extensions still see the parent's host path, and the fast path stays off while the f2fs
+ workaround is active.
+- `0003-clone3-flags-read-guard.patch` - a thread created with `clone3` keeps the flags it
+ inherited when `struct clone_args` cannot be read, instead of being tracked as a fork.
diff --git a/tools/proot/build.sh b/tools/proot/build.sh
new file mode 100755
index 000000000..897e2e017
--- /dev/null
+++ b/tools/proot/build.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+set -euo pipefail
+OUTDIR=$(mkdir -p "$1" && cd "$1" && pwd)
+: "${NDK:?set NDK to the Android NDK root}"
+API=26
+HERE=$(cd "$(dirname "$0")" && pwd)
+. "$HERE/source.env"
+case "$(uname -s):$(uname -m)" in
+ Darwin:*) NDK_HOST=darwin-x86_64 ;;
+ Linux:x86_64) NDK_HOST=linux-x86_64 ;;
+ Linux:aarch64|Linux:arm64) NDK_HOST=linux-aarch64 ;;
+ *) echo "Unsupported build host: $(uname -s) $(uname -m)" >&2; exit 1 ;;
+esac
+TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/$NDK_HOST/bin"
+if [[ ! -d "$TOOLCHAIN" && -d "$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin" ]]; then
+ TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin"
+fi
+CC="$TOOLCHAIN/aarch64-linux-android${API}-clang"
+if [[ ! -x "$CC" ]]; then
+ echo "Android NDK toolchain not found at $TOOLCHAIN" >&2
+ exit 1
+fi
+WORK=$(mktemp -d)
+trap 'rm -rf "$WORK"' EXIT
+fetch() {
+ curl -fsSL --retry 3 -o "$WORK/$1" "$2"
+ echo "$3 $WORK/$1" | shasum -a 256 -c -
+ mkdir -p "$WORK/${1%%.*}"
+ tar -xzf "$WORK/$1" -C "$WORK/${1%%.*}" --strip-components=1
+}
+fetch proot.tar.gz "https://github.com/termux/proot/archive/${PROOT_COMMIT}.tar.gz" "$PROOT_SHA256"
+fetch talloc.tar.gz "https://www.samba.org/ftp/talloc/talloc-${TALLOC_VERSION}.tar.gz" "$TALLOC_SHA256"
+for patch in "$HERE"/patches/*.patch; do
+ patch -d "$WORK/proot" -p1 --forward --quiet < "$patch"
+done
+mkdir -p "$WORK/talloc/config"
+cp "$HERE/talloc-config.h" "$WORK/talloc/config/config.h"
+"$CC" -c -O2 -fPIC -ffile-prefix-map="$WORK"=. -D__STDC_WANT_LIB_EXT1__=1 -DHAVE_CONFIG_H \
+ -I"$WORK/talloc/config" -I"$WORK/talloc" -I"$WORK/talloc/lib/replace" \
+ -o "$WORK/talloc.o" "$WORK/talloc/talloc.c"
+"$TOOLCHAIN/llvm-ar" rcs "$WORK/libtalloc.a" "$WORK/talloc.o"
+mkdir -p "$WORK/bin"
+ln -s "$TOOLCHAIN/llvm-readelf" "$WORK/bin/readelf"
+PATH="$WORK/bin:$PATH" make -C "$WORK/proot/src" -j"$(getconf _NPROCESSORS_ONLN)" \
+ CC="$CC" STRIP="$TOOLCHAIN/llvm-strip" OBJCOPY="$TOOLCHAIN/llvm-objcopy" OBJDUMP="$TOOLCHAIN/llvm-objdump" \
+ CPPFLAGS="-D_FILE_OFFSET_BITS=64 -D_GNU_SOURCE -I. -I$WORK/talloc -DARG_MAX=131072 -Wno-error=implicit-function-declaration" \
+ LDFLAGS="$WORK/libtalloc.a -Wl,-z,noexecstack" \
+ PROOT_UNBUNDLE_LOADER=/nonexistent proot loader
+install -m755 "$WORK/proot/src/proot" "$OUTDIR/libproot.so"
+install -m755 "$WORK/proot/src/loader/loader" "$OUTDIR/libproot-loader.so"
+"$TOOLCHAIN/llvm-strip" --strip-unneeded "$OUTDIR/libproot.so"
+grep -q '^#define HAVE_SECCOMP_FILTER' "$WORK/proot/src/build.h" || { echo "ERROR: proot was built without seccomp acceleration"; exit 1; }
+grep -q '^#define HAVE_PROCESS_VM' "$WORK/proot/src/build.h" || { echo "ERROR: proot was built without process_vm"; exit 1; }
+NEEDED=$("$TOOLCHAIN/llvm-readelf" -d "$OUTDIR/libproot.so" | sed -n 's/.*NEEDED.*\[\(.*\)\]/\1/p' | tr '\n' ' ')
+echo "libproot.so NEEDED: $NEEDED"
+for lib in $NEEDED; do
+ case $lib in libc.so|libdl.so|libm.so) ;;
+ *) echo "ERROR: unexpected dependency $lib"; exit 1 ;;
+ esac
+done
+ls -l "$OUTDIR/libproot.so" "$OUTDIR/libproot-loader.so"
diff --git a/tools/proot/patches/0001-tracee-lookup-by-pid.patch b/tools/proot/patches/0001-tracee-lookup-by-pid.patch
new file mode 100644
index 000000000..3b181ced6
--- /dev/null
+++ b/tools/proot/patches/0001-tracee-lookup-by-pid.patch
@@ -0,0 +1,137 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: maxjivi05
+Date: Wed, 23 Sep 2026 21:44:45 -0400
+Subject: [PATCH] tracee: find tracees by pid, and sweep only after a
+ termination
+
+---
+ src/cli/cli.c | 2 +-
+ src/tracee/event.c | 2 +-
+ src/tracee/tracee.c | 26 +++++++++++++++++++++++++-
+ src/tracee/tracee.h | 2 ++
+ 4 files changed, 29 insertions(+), 3 deletions(-)
+
+diff --git a/src/cli/cli.c b/src/cli/cli.c
+index bbc6400..ec50712 100644
+--- a/src/cli/cli.c
++++ b/src/cli/cli.c
+@@ -465,7 +465,7 @@ int main(int argc, char *const argv[])
+ tracee = get_tracee(NULL, 0, true);
+ if (tracee == NULL)
+ goto error;
+- tracee->pid = getpid();
++ set_tracee_pid(tracee, getpid());
+
+ /* Set verboseness from env variable, may be overriden by option */
+ {
+diff --git a/src/tracee/event.c b/src/tracee/event.c
+index 756aa7e..ec79ef3 100644
+--- a/src/tracee/event.c
++++ b/src/tracee/event.c
+@@ -106,7 +106,7 @@ int launch_process(Tracee *tracee, char *const argv[])
+
+ default: /* parent */
+ /* We know the pid of the first tracee now. */
+- tracee->pid = pid;
++ set_tracee_pid(tracee, pid);
+ return 0;
+ }
+
+diff --git a/src/tracee/tracee.c b/src/tracee/tracee.c
+index a97757d..90894a0 100644
+--- a/src/tracee/tracee.c
++++ b/src/tracee/tracee.c
+@@ -48,6 +48,16 @@
+
+ static Tracees tracees;
+
++#define TRACEE_BUCKETS 1024
++static Tracees buckets[TRACEE_BUCKETS];
++
++static bool terminations_pending = false;
++
++static Tracees *bucket_of(pid_t pid)
++{
++ return &buckets[(unsigned int) pid % TRACEE_BUCKETS];
++}
++
+
+ /**
+ * Remove @zombie from its parent's list of zombies. Note: this is a
+@@ -89,6 +99,7 @@ static int remove_tracee(Tracee *tracee)
+ int event;
+
+ LIST_REMOVE(tracee, link);
++ LIST_REMOVE(tracee, bucket_link);
+
+ /* Clean objects that are linked to this tracee's life
+ * span. */
+@@ -226,6 +237,7 @@ static Tracee *new_tracee(pid_t pid)
+ tracee->vpid = next_vpid++;
+
+ LIST_INSERT_HEAD(&tracees, tracee, link);
++ LIST_INSERT_HEAD(bucket_of(pid), tracee, bucket_link);
+
+ tracee->life_context = talloc_new(tracee);
+
+@@ -327,7 +339,7 @@ Tracee *get_tracee(const Tracee *current_tracee, pid_t pid, bool create)
+ if (current_tracee != NULL && current_tracee->pid == pid)
+ return (Tracee *)current_tracee;
+
+- LIST_FOREACH(tracee, &tracees, link) {
++ LIST_FOREACH(tracee, bucket_of(pid), bucket_link) {
+ if (tracee->pid == pid) {
+ /* Flush then allocate a new memory collector. */
+ TALLOC_FREE(tracee->ctx);
+@@ -340,12 +352,20 @@ Tracee *get_tracee(const Tracee *current_tracee, pid_t pid, bool create)
+ return (create ? new_tracee(pid) : NULL);
+ }
+
++void set_tracee_pid(Tracee *tracee, pid_t pid)
++{
++ tracee->pid = pid;
++ LIST_REMOVE(tracee, bucket_link);
++ LIST_INSERT_HEAD(bucket_of(pid), tracee, bucket_link);
++}
++
+ /**
+ * Mark tracee as terminated and optionally take action.
+ */
+ void terminate_tracee(Tracee *tracee)
+ {
+ tracee->terminated = true;
++ terminations_pending = true;
+
+ /* Case where the terminated tracee is marked
+ to kill all tracees on exit.
+@@ -363,6 +383,10 @@ void free_terminated_tracees()
+ {
+ Tracee *next;
+
++ if (!terminations_pending)
++ return;
++ terminations_pending = false;
++
+ /* Items can't be deleted when using LIST_FOREACH. */
+ next = tracees.lh_first;
+ while (next != NULL) {
+diff --git a/src/tracee/tracee.h b/src/tracee/tracee.h
+index 3dcbc7b..e4be3ff 100644
+--- a/src/tracee/tracee.h
++++ b/src/tracee/tracee.h
+@@ -79,6 +79,7 @@ typedef struct tracee {
+
+ /* Link for the list of all tracees. */
+ LIST_ENTRY(tracee) link;
++ LIST_ENTRY(tracee) bucket_link;
+
+ /* Process identifier. */
+ pid_t pid;
+@@ -300,6 +301,7 @@ typedef struct tracee {
+ #define TRACEE(a) talloc_get_type_abort(talloc_parent(talloc_parent(a)), Tracee)
+
+ extern Tracee *get_tracee(const Tracee *tracee, pid_t pid, bool create);
++extern void set_tracee_pid(Tracee *tracee, pid_t pid);
+ extern Tracee *get_stopped_ptracee(const Tracee *ptracer, pid_t pid,
+ bool only_with_pevent, word_t wait_options);
+ extern bool has_ptracees(const Tracee *ptracer, pid_t pid, word_t wait_options);
diff --git a/tools/proot/patches/0002-canon-resolve-parent-at-once.patch b/tools/proot/patches/0002-canon-resolve-parent-at-once.patch
new file mode 100644
index 000000000..8965ba61f
--- /dev/null
+++ b/tools/proot/patches/0002-canon-resolve-parent-at-once.patch
@@ -0,0 +1,198 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: maxjivi05
+Date: Wed, 23 Sep 2026 21:44:45 -0400
+Subject: [PATCH] canon: resolve a deep parent directory at once, and skip the
+ lstat of a final component that is not followed
+
+---
+ src/path/canon.c | 94 +++++++++++++++++++++++++++++++++++++++++++--
+ src/path/f2fs-bug.c | 7 +++-
+ src/path/f2fs-bug.h | 1 +
+ 3 files changed, 97 insertions(+), 5 deletions(-)
+
+diff --git a/src/path/canon.c b/src/path/canon.c
+index 6c5d4c0..cabd0c0 100644
+--- a/src/path/canon.c
++++ b/src/path/canon.c
+@@ -24,6 +24,7 @@
+ #include /* PATH_MAX, */
+ #include /* MAXSYMLINKS, */
+ #include /* E*, */
++#include
+ #include /* lstat(2), S_ISREG(), */
+ #include /* access(2), lstat(2), */
+ #include /* string(3), */
+@@ -133,7 +134,8 @@ static inline Finality next_component(char component[NAME_MAX], const char **cur
+ * (returned value is 1), otherwise it returns -errno (-ENOENT or
+ * -ENOTDIR).
+ */
+-static inline int substitute_binding_stat(Tracee *tracee, Finality finality, unsigned int recursion_level,
++static inline int substitute_binding_stat(Tracee *tracee, Finality finality, bool deref_final,
++ unsigned int recursion_level,
+ const char guest_path[PATH_MAX], char host_path[PATH_MAX])
+ {
+ struct stat statl;
+@@ -152,6 +154,9 @@ static inline int substitute_binding_stat(Tracee *tracee, Finality finality, uns
+ return status;
+ }
+
++ if (finality == FINAL_NORMAL && !deref_final && tracee->glue_type == 0)
++ return 0;
++
+ statl.st_mode = 0;
+ if (should_skip_file_access_due_to_f2fs_bug(tracee, host_path)) {
+ status = -ENOENT;
+@@ -186,6 +191,87 @@ static inline int substitute_binding_stat(Tracee *tracee, Finality finality, uns
+ return (S_ISLNK(statl.st_mode) ? 1 : 0);
+ }
+
++#define RESOLVE_AT_ONCE_MIN_DEPTH 3
++
++static size_t resolve_parent_at_once(Tracee *tracee, const char *user_path,
++ char guest_path[PATH_MAX])
++{
++ char parent[PATH_MAX];
++ char expected[PATH_MAX];
++ char resolved[PATH_MAX];
++ char link[32];
++ const Binding *binding;
++ const char *last;
++ const char *cursor;
++ size_t parent_length;
++ size_t prefix_length;
++ size_t expected_length;
++ ssize_t length;
++ int depth;
++ int fd;
++
++ if (tracee->glue_type != 0 || f2fs_bug_workaround_active(tracee))
++ return 0;
++
++ last = strrchr(user_path, '/');
++ if (last == NULL || last == user_path || last[1] == '\0'
++ || strcmp(last + 1, ".") == 0 || strcmp(last + 1, "..") == 0)
++ return 0;
++
++ parent_length = last - user_path;
++ if (parent_length >= PATH_MAX)
++ return 0;
++
++ depth = 0;
++ for (cursor = user_path; cursor < last; ) {
++ const char *end;
++
++ end = memchr(cursor + 1, '/', last - cursor);
++ if (end == NULL)
++ end = last;
++ if (end == cursor + 1
++ || (end == cursor + 2 && cursor[1] == '.')
++ || (end == cursor + 3 && cursor[1] == '.' && cursor[2] == '.'))
++ return 0;
++ depth++;
++ cursor = end;
++ }
++ if (depth < RESOLVE_AT_ONCE_MIN_DEPTH)
++ return 0;
++
++ memcpy(parent, user_path, parent_length);
++ parent[parent_length] = '\0';
++
++ binding = get_binding(tracee, GUEST, parent);
++ if (binding == NULL || binding->guest.length != 1)
++ return 0;
++
++ prefix_length = (binding->host.length == 1 ? 0 : binding->host.length);
++ expected_length = prefix_length + parent_length;
++ if (expected_length >= PATH_MAX)
++ return 0;
++ memcpy(expected, binding->host.path, prefix_length);
++ strcpy(expected + prefix_length, parent);
++
++ fd = open(expected, O_PATH | O_DIRECTORY | O_CLOEXEC);
++ if (fd < 0)
++ return 0;
++
++ snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
++ length = readlink(link, resolved, sizeof(resolved));
++ close(fd);
++
++ if (length < 0 || (size_t) length != expected_length
++ || memcmp(resolved, expected, expected_length) != 0)
++ return 0;
++
++ if (notify_extensions(tracee, HOST_PATH, (intptr_t) expected, false) < 0)
++ return 0;
++
++ strcpy(guest_path, parent);
++ return last + 1 - user_path;
++}
++
+ /**
+ * Copy in @guest_path the canonicalization (see `man 3 realpath`) of
+ * @user_path regarding to @tracee->root. The path to canonicalize
+@@ -227,6 +313,8 @@ int canonicalize(Tracee *tracee, const char *user_path, bool deref_final,
+
+ /* Canonicalize recursely 'user_path' into 'guest_path'. */
+ cursor = user_path;
++ if (user_path[0] == '/')
++ cursor += resolve_parent_at_once(tracee, user_path, guest_path);
+ finality = NOT_FINAL;
+ while (!IS_FINAL(finality)) {
+ Comparison comparison;
+@@ -260,7 +348,7 @@ int canonicalize(Tracee *tracee, const char *user_path, bool deref_final,
+ * symlink. For this latter case, we check that the
+ * symlink points to a directory once it is
+ * canonicalized, at the end of this loop. */
+- status = substitute_binding_stat(tracee, finality, recursion_level, scratch_path, host_path);
++ status = substitute_binding_stat(tracee, finality, deref_final, recursion_level, scratch_path, host_path);
+ if (status < 0)
+ return status;
+
+@@ -338,7 +426,7 @@ int canonicalize(Tracee *tracee, const char *user_path, bool deref_final,
+
+ /* Check that a non-final canonicalized/dereferenced
+ * symlink exists and is a directory. */
+- status = substitute_binding_stat(tracee, finality, recursion_level, guest_path, host_path);
++ status = substitute_binding_stat(tracee, finality, deref_final, recursion_level, guest_path, host_path);
+ if (status < 0)
+ return status;
+
+diff --git a/src/path/f2fs-bug.c b/src/path/f2fs-bug.c
+index 4a5ee45..e4bddb9 100644
+--- a/src/path/f2fs-bug.c
++++ b/src/path/f2fs-bug.c
+@@ -113,7 +113,7 @@ end:
+ return result;
+ }
+
+-bool should_skip_file_access_due_to_f2fs_bug(const Tracee *tracee, const char *path) {
++bool f2fs_bug_workaround_active(const Tracee *tracee) {
+ /* On first call check if workaround should be enabled */
+ static bool f2fs_bug_probed;
+ static bool f2fs_bug_detected;
+@@ -130,9 +130,12 @@ bool should_skip_file_access_due_to_f2fs_bug(const Tracee *tracee, const char *p
+ }
+ f2fs_bug_probed = true;
+ }
++ return f2fs_bug_detected;
++}
+
++bool should_skip_file_access_due_to_f2fs_bug(const Tracee *tracee, const char *path) {
+ /* If workaround is not active don't skip access to file */
+- if (!f2fs_bug_detected) {
++ if (!f2fs_bug_workaround_active(tracee)) {
+ return false;
+ }
+
+diff --git a/src/path/f2fs-bug.h b/src/path/f2fs-bug.h
+index b8eabdf..360f149 100644
+--- a/src/path/f2fs-bug.h
++++ b/src/path/f2fs-bug.h
+@@ -4,6 +4,7 @@
+ #include /* bool, true, false, */
+ #include "tracee/tracee.h" /* Tracee, */
+
++bool f2fs_bug_workaround_active(const Tracee *tracee);
+ bool should_skip_file_access_due_to_f2fs_bug(const Tracee *tracee, const char *path);
+
+ #endif /* F2FS_BUG_H */
diff --git a/tools/proot/patches/0003-clone3-flags-read-guard.patch b/tools/proot/patches/0003-clone3-flags-read-guard.patch
new file mode 100644
index 000000000..773297951
--- /dev/null
+++ b/tools/proot/patches/0003-clone3-flags-read-guard.patch
@@ -0,0 +1,30 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: maxjivi05
+Date: Wed, 23 Sep 2026 21:44:45 -0400
+Subject: [PATCH] tracee: keep a thread's inherited clone flags when clone3's
+ arguments cannot be read
+
+---
+ src/tracee/tracee.c | 9 +++++----
+ 1 file changed, 5 insertions(+), 4 deletions(-)
+
+diff --git a/src/tracee/tracee.c b/src/tracee/tracee.c
+index 90894a0..7c0c50c 100644
+--- a/src/tracee/tracee.c
++++ b/src/tracee/tracee.c
+@@ -423,10 +423,11 @@ int new_child(Tracee *parent, word_t clone_flags)
+ status = fetch_regs(parent);
+ if (status >= 0 && get_sysnum(parent, CURRENT) == PR_clone)
+ clone_flags = peek_reg(parent, CURRENT, SYSARG_1);
+- else if (status >= 0 && get_sysnum(parent, CURRENT) == PR_clone3)
+- // Look at the first word of the clone_args structure, which
+- // contains the usual clone flags.
+- clone_flags = peek_word(parent, peek_reg(parent, CURRENT, SYSARG_1));
++ else if (status >= 0 && get_sysnum(parent, CURRENT) == PR_clone3) {
++ word_t flags = peek_word(parent, peek_reg(parent, CURRENT, SYSARG_1));
++ if (errno == 0)
++ clone_flags = flags;
++ }
+
+ /* Get the pid of the parent's new child. */
+ status = ptrace(PTRACE_GETEVENTMSG, parent->pid, NULL, &pid);
diff --git a/tools/proot/source.env b/tools/proot/source.env
new file mode 100644
index 000000000..1d705b893
--- /dev/null
+++ b/tools/proot/source.env
@@ -0,0 +1,4 @@
+PROOT_COMMIT=4dba3afbf3a63af89b4d9c1a59bf2bda10f4d10f
+PROOT_SHA256=de94f648547d1b62431073b82dc99d56ef0ddcef6da022e0dfffe2bf661cc34e
+TALLOC_VERSION=2.4.3
+TALLOC_SHA256=dc46c40b9f46bb34dd97fe41f548b0e8b247b77a918576733c528e83abd854dd
diff --git a/tools/proot/talloc-config.h b/tools/proot/talloc-config.h
new file mode 100644
index 000000000..ef7a805f1
--- /dev/null
+++ b/tools/proot/talloc-config.h
@@ -0,0 +1,34 @@
+#define TALLOC_BUILD_VERSION_MAJOR 2
+#define TALLOC_BUILD_VERSION_MINOR 4
+#define TALLOC_BUILD_VERSION_RELEASE 3
+#define HAVE_STDBOOL_H 1
+#define HAVE_BOOL 1
+#define HAVE_STDINT_H 1
+#define HAVE_INTTYPES_H 1
+#define HAVE_STRING_H 1
+#define HAVE_STRINGS_H 1
+#define HAVE_STDLIB_H 1
+#define HAVE_UNISTD_H 1
+#define HAVE_SYS_TYPES_H 1
+#define HAVE_DLFCN_H 1
+#define HAVE_STRNLEN 1
+#define HAVE_STRNDUP 1
+#define HAVE_STRLCPY 1
+#define HAVE_STRLCAT 1
+#define HAVE_USLEEP 1
+#define HAVE_VA_COPY 1
+#define HAVE_VSNPRINTF 1
+#define HAVE_SNPRINTF 1
+#define HAVE_C99_VSNPRINTF 1
+#define HAVE_INTPTR_T 1
+#define HAVE_UINTPTR_T 1
+#define HAVE_PTRDIFF_T 1
+#define HAVE_SIG_ATOMIC_T_TYPE 1
+#define HAVE_ERRNO_DECL 1
+#define HAVE_MEMMOVE 1
+#define HAVE_CONSTRUCTOR_ATTRIBUTE 1
+#define HAVE_DESTRUCTOR_ATTRIBUTE 1
+#define HAVE_VISIBILITY_ATTR 1
+#define HAVE_FALLTHROUGH_ATTRIBUTE 1
+#define HAVE_SYS_AUXV_H 1
+#define HAVE_GETAUXVAL 1