Skip to content

feat(android): #117 proot hardening — fake /proc binds + bun wrapper - #122

Merged
zo-sol merged 11 commits into
mainfrom
feat/115-proot-hardening
Jul 15, 2026
Merged

feat(android): #117 proot hardening — fake /proc binds + bun wrapper#122
zo-sol merged 11 commits into
mainfrom
feat/115-proot-hardening

Conversation

@mega123-art

Copy link
Copy Markdown
Contributor

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):

  1. 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). Breaks node 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.

  2. 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_modules under proot. /usr/local/bin/bun wrapper pre-creates node_modules + forces --backend=copyfile for installs. Plain bun add express66 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)

  • fake /proc: previously-denied files now readable+correct in-guest; node os.loadavg()/os.cpus() work.
  • bun: which bun → /usr/local/bin/bun (Installer-written), plain bun add express → 66 packages / 594 files / require OK.
  • server: 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

mega123-art and others added 5 commits July 13, 2026 11:15
…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>
mega123-art and others added 3 commits July 14, 2026 15:50
…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>
@mega123-art

Copy link
Copy Markdown
Contributor Author

Verified on-device (patched binary from CI run 29325627361):

  • ln hardlink → copy fallback (inodes differ), dpkg --configure -a self-heals (/usr/bin/cc configured), cargo build → runs "Hello, world!", git clone 0 fsck / 240 files, bun add 594 files require OK, server HTTP 200.
  • bun-wrapper regression fixed (kept --backend=copyfile as belt-and-suspenders; commit 923a3a7).
  • CI workflow workdir bug fixed (commit a722835) — proot-from-source step now succeeds.

All tools green: git ✅ pnpm ✅ npm ✅ bun ✅ cargo ✅ apt/dpkg ✅.

mega123-art and others added 3 commits July 14, 2026 16:43
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.
@zo-sol
zo-sol merged commit 28a8bf7 into main Jul 15, 2026
zo-sol added a commit that referenced this pull request Jul 15, 2026
The proot investigation is resolved and shipped (#116/#117/#122); these debug
probes and experimental C shims are no longer needed.
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