DEV Community

RYO ITABASHI
RYO ITABASHI

Posted on AI-assisted

Running bash and node inside an Android app: four SELinux walls and the patterns that got past them

If you ship a terminal, a build tool, or an agent runtime inside a normal Android app, you will eventually try to run a bundled ELF or a shell script from your app's private storage. On my test device, almost every obvious way of doing that was refused.

This post covers four places where that happened in Shelly, an open-source AI terminal and agent runtime for Android, what the errors looked like, and what finally worked. Everything below was observed on one device, a Samsung Galaxy Z Fold6, so treat it as a field report, not a spec.

TL;DR

  • From an untrusted_app process, three things failed: am start from the shell, executing shebang scripts that live in app data, and calling execve() directly on ELFs that live in app data. The last two match a documented rule for apps targeting API 29+ on Android 10 and later.
  • On this device, three patterns got past them: launch ELFs through /system/bin/linker64, make anything on PATH a native binary and pair it with a linker64 launch, and send shell → app signals through a file queue the app polls.
  • Pass scripts to the interpreter as an argument (bash script.sh). Running ./script.sh directly still fails.

The setup

Shelly gets its PTY from JNI forkpty. It ships bash, Node.js, Python, git, curl and other tools inside the APK as .so files, and a LibExtractor unpacks them into the app's private data directory (files/termux-libs) on first launch. It doesn't use the Termux app. So the main tools, and the scripts the app generates, live in app private data (SELinux label app_data_file), and processes started from Shelly normally run in the app UID's SELinux domain (untrusted_app). All four problems below come from that combination.

I direct the work; AI coding agents write the implementation, running both on the phone and on a PC, and every fix is checked on the phone. This post was also drafted with AI assistance and fact-checked against the repository by me.

The documented rule underneath Walls 2–4

The Android 10 behavior-changes page (Behavior changes: apps targeting Android 10, section "Removed execute permission for app home directory") says that untrusted apps targeting API 29 or higher cannot call execve() directly on files in the app's writable home directory. Executing from a writable location is a W^X violation, and apps are expected to run only binary code embedded in their APK. In SELinux policy terms, untrusted_app has no execute_no_trans permission on app_data_file.

Shelly targets SDK 36, so this rule applies as written. I haven't verified whether Samsung Knox adds restrictions on top of it. I also didn't keep the avc: denied lines for these four cases, so the mapping below is consistent with the official spec but not confirmed from logs.

Wall 1: am start from the shell is refused

Symptom. To finish CLI OAuth logins, the shell needed to open a URL in the app's built-in browser pane. Every am start variant returned:

cmd: Failure calling service activity: Failed transaction (2147483646)
Enter fullscreen mode Exit fullscreen mode

Plain, with -W, with -f 0x10000000, with an https URL or a custom shelly:// scheme: the result never changed (confirmed on-device 2026-05-08). Worse, an older Codex login helper had been swallowing this failure in a callback and printing "→ opened Shelly Browser Pane". That message had been false the whole time.

Cause (not isolated). FAILED_TRANSACTION (2147483646) means the binder transaction itself failed; it doesn't say where the request was refused. I didn't isolate the cause. The candidates are Android's background activity start restrictions (BackgroundActivityStartController), a Samsung-specific policy, and an SELinux denial of cmd passing the caller's tty or pipe fds to system_server. None of them is confirmed.

A known alternative path is the one Termux uses: an am implementation that runs under app_process. I haven't tested it in Shelly.

Fix. Stop asking the system to start the app. Have the app pick up the request itself:

  • A small native xdg-open replacement opens $HOME/.shelly-deep-link-queue with O_APPEND and writes each URL as one line in a single write().
  • The React Native side polls that file about every 250 ms, drains it, and calls the browser store's openUrl() directly. This updates state inside the already-running Activity instead of starting a new one. Links that do have to leave the app (Google OAuth, for example) still start Custom Tabs or Chrome from the foreground Activity, which is an allowed start.
  • On Linux local filesystems, lines written this way don't interleave in practice. That isn't a POSIX guarantee, though: POSIX only promises write atomicity for pipes and FIFOs. If you need a stronger guarantee, use a lock, write-then-rename, or one spool file per writer.
  • The queue accepts only http and https. Passing file://, content:// or intent:// through a queue into a WebView would open a path to privilege escalation, so both the C writer and the RN reader validate.

Wall 2: shebang scripts on PATH fail with "bad interpreter: Success"

Symptom. The first xdg-open replacement was a shell script. Three shebangs were tried:

Version Shebang Result
v78 #!/system/bin/sh bad interpreter: Success
v79 #!$HOME/bin/bash same
v80 #!/system/bin/linker64 ...libbash.so stopped before deploy after review predicted the same failure

Cause. The documented rule above. When you execute a script directly, the kernel checks exec permission on the script file itself before it ever starts the interpreter. A script in app data fails that check, so no shebang can help.

An error that ends in "Success" tells you nothing useful. My guess is that errno was overwritten by some other call before strerror ran. That's an inference; I haven't verified it.

Fix. v81 dropped scripts and used a native binary. It's written in C, built with the NDK clang using -fPIE -pie, packaged as jniLibs/arm64-v8a/libshelly_xdg_open.so, unpacked into app data by LibExtractor, and linked from .bashrc:

# simplified from the .bashrc that HomeInitializer.kt generates
ln -sf "$SHELLY_LIB_DIR/shelly_xdg_open" "$HOME/bin/xdg-open"
export BROWSER="$HOME/bin/xdg-open"
Enter fullscreen mode Exit fullscreen mode

A native ELF on PATH isn't enough by itself. The extracted file still sits in private app data, so the same exec rule applies to it. Shelly pairs these binaries with either the per-command libexec_wrapper.so (see Wall 3) or an explicit linker64 launch.

Five months later the same class of failure hit $HOME/bin/shelly, which was still a #!/system/bin/sh script. Any caller that didn't have the interactive shell function (bash -c, sh -c, a node child_process) got:

.../home/bin/shelly: /system/bin/sh: bad interpreter: Permission denied
Enter fullscreen mode Exit fullscreen mode

The fix used the same pattern (jni/shelly-cmd.c → libshelly_cmd.so → symlink). On build 2818 (2026-11-03) it returned rc=0 in four contexts: inside bash script.sh, under sh -c, under bash -c, and from node spawnSync('shelly').

Wall 3: a bash wrapper, and why ./script.sh still fails

Symptom. To make bash script.sh work, the first attempt put a #!/system/bin/sh wrapper at $HOME/bin/bash. It failed with bad interpreter: Permission denied (2026-04-16).

Cause. The same rule as Wall 2. Both the wrapper script and the ELF behind it live in app data, so neither can be execve()d directly.

Fix. Replace the wrapper file with a shell function in .bashrc, and never exec app-data ELFs directly. Hand them to the system dynamic linker:

# Run binary via linker64 (SELinux blocks direct execve on app_data_file)
_run() {
  case "${1:-}" in
    /*) LD_LIBRARY_PATH="$SHELLY_LD_LIBRARY_PATH" /system/bin/linker64 "$@" ;;
    *) printf '_run: first arg must be an absolute path, got: %s\n' "${1:-(empty)}" >&2; return 64 ;;
  esac
}
Enter fullscreen mode Exit fullscreen mode

bash(), node(), git() and the other tool functions are built on _run. Shell functions take priority over PATH, so bash -c '…' and bash script.sh now work.

Child processes changed along the way. Shelly used to set LD_PRELOAD=libexec_wrapper.so for the whole PTY. When that interposer crashed, every new tab crashed before its prompt, so v180 removed the PTY-wide preload. Today the library is injected per command, only where it's needed: git, make, vim and similar tools, plus bash / sh when the interactive shell calls them with a script or -c. The library rewrites each child execve() into a linker64 /proc/self/fd/N launch.

That removal broke something. A shelly() function that still execve()d the $HOME/bin/bash symlink directly started failing with Permission denied (bug #166), because it had been relying on the PTY-wide preload. The fix routed it through _run like everything else (a9d403f1d: the symptom was reproduced on the device and the fix implemented; that commit doesn't record a post-fix device check).

Running a shebang script directly still fails. A device audit on 2026-08-17 got exit 126; the recorded essence of the output is:

/usr/bin/env: bad interpreter: Permission denied
Enter fullscreen mode Exit fullscreen mode

So the project rule is: always run scripts as bash script.sh. When the script is an argument, bash just reads the file, and nothing checks the script's own exec permission.

Wall 4: raw node in generated agent scripts exits 126

Symptom. Shelly's autonomous agents run as shell scripts the app generates, and those scripts call node and curl. Calling node directly in that context exited 126, which I attribute to the SELinux/W^X exec denial.

Cause. The node() function in the interactive .bashrc doesn't exist in a generated, non-interactive script. A bare node resolves to the app-data ELF on PATH and calls execve() on it directly, which is the same rule as Wall 3.

Fix. Put the same linker64 routing into the generated script and replace every node/curl call (script version 5 → 6):

shelly_run_app_binary() {
  name="$1"; shift
  binary=$(shelly_app_binary_path "$name") || return 127
  binary_dir="${binary%/*}"
  if [ -x /system/bin/linker64 ]; then
    LD_LIBRARY_PATH="${SHELLY_LD_LIBRARY_PATH:-${SHELLY_LIB_DIR:-$binary_dir}}" /system/bin/linker64 "$binary" "$@"
    return $?
  fi
  "$binary" "$@"
}
shelly_node() { shelly_run_app_binary node "$@"; }
Enter fullscreen mode Exit fullscreen mode

If /system/bin/linker64 is missing, the helper falls back to running "$binary" directly, which would hit the same rule.

A note on diagnosis: exit 126 means "found but not executable", and that has many causes (missing mode bits, a noexec mount, a bad interpreter, a wrong binary format, and so on). By itself it doesn't prove an SELinux denial. To check for one, run adb logcat | grep 'avc: denied' from a PC; the app can't read its own avc lines.

A checklist from Shelly's constraints

The usual first choice is an official mechanism. For talking between apps or processes: explicit Intents, PendingIntent, a bound Service over Binder, or a ContentProvider. For native code: load it through JNI, or run files that the package manager installed from jniLibs into ApplicationInfo.nativeLibraryDir, which is the documented place where an app may execute its own native files.

Shelly's case is narrower. It's installed with packaging that leaves jniLibs inside the APK instead of extracting them, and on this device nativeLibraryDir was empty. v49 tried to use it and v53 gave up. So the list below is what Shelly chose under those conditions:

  1. On this device, app-data ELFs could be launched through linker64. Wrap /system/bin/linker64 <absolute path> args… in one function or helper, and reject relative paths with a clear error. This is a device-dependent workaround I observed, not a public or portable API: it only covers 64-bit bionic ELFs, it has PIE and linker-namespace constraints, and an OS update could change it. In later work, starting glibc's ld.so through linker64 failed.
  2. Only native binaries go on PATH, paired with a linker64 launch. A script on PATH fails the first time anything execs it directly.
  3. Pass scripts as arguments. Use bash script.sh or linker64 libbash.so script.sh, and never depend on shebang resolution.
  4. A file queue for shell → app signals is a same-UID, limited-purpose design choice. Validate on both the writer and reader side.
  5. Apply the same rules to generated scripts and child processes. Interactive shell functions don't carry over to them.
  6. Don't swallow exit codes. A helper that prints "opened" without checking the result hides a platform limit for months.

Limits of this report

  • N=1. Only a Galaxy Z Fold6 was tested. The Android version isn't even consistent in the project's own records: the 2026-05-08 commit says Android 14, while other notes on the same device from April 2026 onward say Android 16.
  • Knox-specific restrictions aren't verified. Walls 2–4 are explained by the documented AOSP rule, but nothing was tested on non-Samsung hardware. The cause of Wall 1 is unknown.
  • No avc: denied lines for these four cases were kept. The mapping of each failure to a rule is inferred from the official spec and the observed behavior.
  • A file queue only works while the app is running. The 250 ms RN poll stops when the app is in the background. A different feature hit exactly that timeout and moved to a native FileObserver.
  • Not every path is covered. A shelly call from a parent that doesn't load the exec wrapper (for example, a /system/bin/sh invoked by full path) still can't execute.

References


Drafted with AI assistance; facts checked against the repository by the author.
RYO ITABASHI · github.com/RYOITABASHI/Shelly

Top comments (0)