feat(android): #117 proot hardening — fake /proc binds + bun wrapper - #122
Merged
Conversation
…ect=rename Teed up on top of Zo's root-cause hunt (link() false-success on .git/objects under untrusted_app). Verified against git v2.43.0 object-file.c: with core.createObject=rename git skips link() entirely and uses rename(), which survives proot (probe6). So the interim fix is one line of git config — no shim, no proot rebuild. - probes/probe10.sh: reproduces the loss in link mode, shows it gone in rename mode, then a real network clone with the fix. Run right after probe9 confirms. - shims/liblinkfix.c: broader LD_PRELOAD option (EXDEV on object-path link/ linkat → caller's rename fallback) for non-git tools that lack the config knob. - README: fix ladder — (1) core.createObject=rename config, (2) liblinkfix shim, (3) basement proot linkat patch. All gated on probe9 naming link as the culprit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… loss
proot's link() is a false success under untrusted_app: it returns 0 but the
target file never appears (proven on-device SM-A356E: `link ret=0
target_exists_after=0`, 0/50 loose objects survive). git's default finalize
(mkstemp -> link -> unlink) trusts the 0 and reports success, so clone/commit/
fetch silently lose objects ("remote did not send all necessary objects").
Write a system /etc/gitconfig at install with core.createObject=rename, which
makes git skip link() and use rename() (which proot handles correctly). Covers
every git — the node server's and the user's interactive shell — not just clone.
Verified end-to-end via the shipping path (system gitconfig, plain `git clone`,
no per-command flag): express.git clones exit 0, 53322 objects in-pack, clean
checkout, 0 fsck errors. Supersedes the clone-only dulwich shim (#114); the
all-tools basement fix (proot linkat patch) stays tracked in #115.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aunch configureGuest only runs on a fresh MARKER, so the #115 git fix (previous commit) would miss devices that installed before it: an APK update hits the isInstalled early return and never rewrites /etc. Extract the write into writeGuestGitConfig() and also call it on the already-installed path — idempotent 30-byte write, far cheaper than a MARKER bump + rootfs re-extract. Verified on-device (SM-A356E): app already installed ("already installed" log, no re-extract) → delete /etc/gitconfig, relaunch → recreated; fresh install writes it via configureGuest as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause, proven on-device (SM-A356E, untrusted_app): the kernel denies hardlinks in the untrusted_app domain (`ln a b` -> EACCES). --link2symlink catches that and FAKES success (pokes syscall result 0) via a symlink-refcount scheme whose links dangle in the guest namespace. The fake defeats callers' error handling: git's finalize_object_file falls back to rename, and pnpm's store-linker falls back to copy, ONLY when link FAILS — never when it lies with a 0. Hence silent git object loss and broken pnpm installs. Removing --link2symlink makes linkat fail honestly with EACCES, so every tool with a link fallback recovers on its own. Verified on-device with l2s off: - git (forced link mode): 50/50 loose objects survive (rename fallback) - pnpm (default hardlink): 589/589 files, require OK (copy fallback) - node server still boots: server ready (HTTP 200) — no regression This is the basement fix: one flag removed fixes git + pnpm + any link()-using tool, with no proot rebuild and no per-tool config. The core.createObject=rename gitconfig (prior commits) stays as belt-and-suspenders (skips the doomed link attempt for git). Supersedes the clone-only dulwich shim (#114). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(all-tools basement) Two proot-guest improvements, both proven on-device (SM-A356E / Android 16, untrusted_app) through the guest-script runner (real app SELinux domain): 1) Fake /proc (fixes a whole class, not one tool) Under untrusted_app Android DENIES the guest read access to a set of /proc files — verified: `cat /proc/loadavg`, `/proc/version`, `/proc/sys/fs/inotify/max_user_watches` all "Permission denied". This breaks node os.loadavg()/os.cpus() (loadavg, stat), inotify watchers (vite/chokidar), capsh/apt (cap_last_cap), id-mapping (overflowuid/gid). Mirror proot-distro: Installer lays down fake files (.sysdata/), DirectProotExec binds only the ones the app can't read (never shadows a working /proc). After: all readable+correct, node os.loadavg() -> [0.12,0.07,0.02], os.cpus() -> 8. Server still HTTP 200. 2) bun wrapper (bun works under proot — it is NOT a ptrace ceiling) Refuted the #117 "parallel fd-relative is proot's ceiling" claim by direct experiment: proot correctly resolves dirfd-relative openat across 14 parallel forked workers, and handles openat2 + every RESOLVE_* flag. bun's real failures are its own defaults: (a) default hardlink backend hits the kernel hardlink denial (EACCES) and fails silently; (b) it won't create node_modules itself under proot (ENOENT). A /usr/local/bin/bun wrapper (PATH-ahead, like the old git shim) pre-creates node_modules + forces --backend=copyfile for installs. After: plain `bun add express` -> 66 packages, 594 files, require() OK (was 0 files). Thin tool-config layer, same shape as git's core.createObject=rename (#116). Both writes run every launch (idempotent) so existing installs get them without a rootfs re-extract. Guest-runner (feat/115-native-exec-probe) used only to test; not included here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…enders) The copy-on-link work removed --backend=copyfile from the bun wrapper to rely solely on PRoot's new --copy-on-link. But that flag only takes effect once the source-built PRoot ships (DirectProotExec gates it on the binary containing the option string); with today's prebuilt binary the wrapper would run bun's default hardlink backend -> kernel EACCES -> "Failed to install N packages", empty node_modules (proven on-device). Restore --backend=copyfile: it is correct with OR without --copy-on-link (same result, bun just copies in userspace) and removes the regression window, mirroring how #116 keeps core.createObject=rename as belt-and-suspenders. node_modules pre-create is also unrelated to hardlinks, so --copy-on-link never covered it — kept. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…/all-tools basement) The durable basement fix for the whole hardlink class. Under untrusted_app the kernel denies hardlinks (EACCES); tools with a copy/rename fallback recover once --link2symlink is gone (#116), but dpkg has NO fallback — link(status,status-old) -> EACCES leaves apt half-configured (proven on-device). Per-tool nudges don't scale to dpkg/coreutils, so fix it in PRoot. - surfaces/android/proot/: GPLv2 patch adding an opt-in --copy-on-link extension. PRoot attempts the real link()/linkat() first; ONLY on EACCES it byte-copies a regular source file to a fresh O_EXCL destination (snapshot, not inode-sharing) and reports success. Special files, symlink sources, AT_EMPTY_PATH, and other errno keep normal behavior. README pins the reproducible source inputs. - android-assets.yml: build PRoot from the pinned Termux recipe + this patch via the official package-builder, export PROOT_DEB to the asset step. - build-assets.sh: fetch_deb accepts the source-built .deb path (PROOT_DEB) as well as the runtime-lib URLs. - DirectProotExec: pass --copy-on-link, but ONLY if the shipped binary advertises it (grep the option string) — an older reused android-assets artifact never gets an unknown flag that would abort the guest boot. Not yet built/verified on-device — needs an android-assets run to produce the patched binary, then on-device: link EACCES copies, git clone, bun add, and apt install + dpkg --configure -a all clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
run-docker.sh reads ./scripts/profile-relaxed.apparmor relative to CWD and the build ran from the workspace root -> "No such file" (run failed in 30s). Run the step from .termux-packages, and since `docker exec` lands in /home/builder, cd into the bind-mounted /home/builder/termux-packages before build-package.sh. Fix the PROOT_DEB export path accordingly (/work/.termux-packages/...). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
Author
|
Verified on-device (patched binary from CI run 29325627361):
All tools green: git ✅ pnpm ✅ npm ✅ bun ✅ cargo ✅ apt/dpkg ✅. |
The shim (guest/git-clone-shim.sh -> /usr/local/bin/git, routing `git clone` through dulwich) worked around #112 by using create+rename instead of git's link path. The actual root cause — proot's --link2symlink faking link() success under untrusted_app — is now fixed at the launch layer (#115/#116, DirectProotExec drops --link2symlink), so native git clone works with real git. Removes the shim install + python3-dulwich dep from build-assets.sh and deletes guest/git-clone-shim.sh + guest/agentnet-git-clone.py. Real /usr/bin/git is used for every command. GATE (before merge): verify real `git clone` end-to-end in the untrusted_app domain with l2s removed (hash-object 50/50 already confirms real git's object finalize works post-fix; clone adds index-pack over the same path). Takes effect on the next rootfs rebuild; existing installs keep the (now harmless) shim until a rootfs re-extract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # surfaces/android/app/src/main/java/com/iqlabs/agentnet/DirectProotExec.kt # surfaces/android/app/src/main/java/com/iqlabs/agentnet/Installer.kt
The shim was dropped from fresh rootfs builds, but installs at marker v5 (and extracts of a pre-#115 bundled tar) still carry it at /usr/local/bin/git, shadowing the now-working native git with the dulwich clone path forever. Delete it every launch — the same reach-existing-installs pattern this PR uses for the bun wrapper — instead of a heavy marker bump. Also refresh comments that still described the shim as present.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Two proot-guest improvements for
untrusted_app(targetSdk 35), both proven on-device (SM-A356E / Android 16) via the guest-script runner (real app SELinux domain):Fake
/proc— Android denies the guest/proc/loadavg,/proc/version,/proc/sys/fs/inotify/max_user_watches,cap_last_cap,overflowuid/gid(all "Permission denied" on-device). Breaksnode os.loadavg()/os.cpus(), inotify watchers (vite/chokidar), capsh/apt, id-mapping. Installer lays down fake files; DirectProotExec binds only the unreadable ones (proot-distro's pattern). After: all readable+correct,os.loadavg()→[0.12,0.07,0.02],os.cpus()→ 8.bun wrapper — bun is not a ptrace ceiling (dirfd-parallel openat + openat2/RESOLVE all verified working, see Guest execution architecture: reaching "any Linux tool works" beyond proot's ptrace ceiling #117). Its failures are its own defaults: hardlink backend hits the kernel hardlink denial (EACCES), and it won't create
node_modulesunder proot./usr/local/bin/bunwrapper pre-createsnode_modules+ forces--backend=copyfilefor installs. Plainbun add express→ 66 packages, 594 files, require() OK (was 0 files).Both run every launch (idempotent) so existing installs get them with no rootfs re-extract. Server still boots HTTP 200.
Proof (on-device,
untrusted_app)node os.loadavg()/os.cpus()work.which bun → /usr/local/bin/bun(Installer-written), plainbun add express→ 66 packages / 594 files / require OK.http://localhost:4317/→server ready (HTTP 200).Full evidence + the refuted "ptrace ceiling" analysis: #117. Relation: builds on #116 (l2s drop, git config). Guest-runner used to test lives on
feat/115-native-exec-probe, not included here.🤖 Generated with Claude Code