Skip to content

Android (Termux): every confined file operation fails with EACCES — open_confined opens / with O_RDONLY|O_DIRECTORY, which SELinux denies to apps #6980

Closed

Description

@printf033

Summary

Since v0.10.1, on Android/Termux every engine feature that goes through the confined file opener (WorkspaceFile::open_confined in crates/tui/src/fleet/files.rs) fails with Permission denied (os error 13):

  • workflow journal unreadable (~/.codewhale/workflow-runs.jsonl): Permission denied on every session start
  • context compaction failed ... error=Permission denied (os error 13) — and the turn is then settled as Failed, which is the most visible symptom
  • spillover: could not save full tool output before cutting it for model context err=... PermissionDenied
  • skill loading via fs_confined::read_to_string fails (read cannot expose... style errors aside, direct SKILL.md loads return EACCES)

Everything that does not go through open_confined (plain std::fs paths such as save_checkpoint, shell tool output, bash subprocesses) works fine, which makes the failure look random.

Logs from v0.9.x sessions on the same device show zero occurrences of these warnings; they start exactly at the 0.10.1 install and now appear in every session.

Root cause

open_confined anchors its openat walk at the filesystem root:

let fd = unsafe {
    libc::open(
        c"/".as_ptr(),
        libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
    )
};

On Android, app processes run in the untrusted_app SELinux domain. That domain has search (traverse) permission on / and on mount-point directories such as /data and /data/data, but not read, so open("/", O_RDONLY|O_DIRECTORY) returns EACCES even though the DAC bits are drwxr-xr-x. Every WorkspaceFile operation therefore fails before it even reaches the workspace, and com.termux (uid u0_a435) is affected as untrusted_app (domain confirmed via /proc/self/attr/current = u:r:untrusted_app_27:s0:c179,c257,c512,c768).

Verified with strace on a live session — the only non-environment EACCES calls in the entire process lifetime are:

openat(AT_FDCWD, "/", O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = -1 EACCES (Permission denied)

(52 occurrences in one session, interleaved with the failing journal/compaction/spillover warnings.)

DAC permissions, ownership, fcntl record locks (EAGAIN, not EACCES, when contended), disk/inode headroom, TMPDIR, and multi-process concurrent appends were all checked and are not factors. realpath/opendir on the same paths succeed — the failure is specific to opening / as a readable directory.

Minimal reproducer

Run inside Termux (python3 android-repro.py, attached):

workspace: /data/data/com.termux/files/home
open('/', O_RDONLY|O_DIRECTORY)  [current open_confined anchor]: FAIL errno=13 (Permission denied)
open('/', O_PATH|O_DIRECTORY)    [proposed fix anchor]:        OK
confined walk (fix):             OK
confined write/read through fix: OK

Proposed fix (patch attached, against main as of 2026-10-14)

Open the anchor and every intermediate component with O_PATH instead of O_RDONLY|O_DIRECTORY, and re-materialize the file's immediate parent as a real directory fd after the walk:

  • O_PATH|O_DIRECTORY still fails with ENOTDIR on non-directories, so the per-component type check and the symlink refusal are unchanged.
  • O_PATH requires no read permission, so SELinux search-only domains (Android apps, hardened sandboxes) work.
  • The final parent is reopened with real flags (openat(pinned_parent, ".")) because O_PATH fds cannot be fsynced (EBADF) or read, and WorkspaceFile uses the directory fd for sync_all, renameat, unlinkat afterwards. This reopen cannot escape the walk: it resolves . inside the pinned parent.
  • Final file opens (open_with_flags, atomic_write, mkdirat) are untouched.

Verified on-device that with the fix the full WorkspaceFile semantics work end-to-end: journal read, O_CREAT|O_EXCL leaf creation, mkdirat branch, atomic tmp+rename, fsync on the materialized parent, fstat/dup on the directory fd.

Environment

  • codewhale 0.10.1 (fead51e), GitHub Releases aarch64 build
  • Android 16 (Termux app, untrusted_app_27 SELinux domain, targetSdk profile c179/c257/c512/c768)
  • Workload: multiple parallel TUI sessions; failure rate 100% for compaction once context passes the auto-compact threshold

Happy to test a patched build on the device.

Activity

  1. added
    needs-triageNew external report awaiting maintainer triage; repro, logs and version output help
    on Oct 14, 2026
  2. printf033 commented on Oct 14, 2026

    @printf033
    Author

    Attaching the minimal reproducer inline (single file, no deps; run with python3 android-repro.py inside Termux):

    #!/usr/bin/env python3
    """Minimal reproducer: confined open fails on Android/Termux, O_PATH fixes it.
    
    Run inside any Termux (or other untrusted_app SELinux domain) shell:
    
        python3 android-repro.py
    
    Expected on an affected device:
        open(/, O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC) -> EACCES   <-- codewhale 0.10.1 open_confined()
        open(/, O_PATH|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC)   -> OK       <-- proposed fix
        confined read after fix: OK
    
    On a normal Linux box both opens succeed and the script prints OK for all.
    """
    import os
    import sys
    
    O_RDONLY = os.O_RDONLY
    O_DIRECTORY = os.O_DIRECTORY
    O_NOFOLLOW = os.O_NOFOLLOW
    O_CLOEXEC = os.O_CLOEXEC
    O_PATH = getattr(os, "O_PATH", 0o10000000)
    
    WORKSPACE = os.getcwd()
    TARGET_REL = ".codewhale/android-repro-probe"
    
    
    def try_open(path, flags):
        try:
            fd = os.open(path, flags)
            os.close(fd)
            return True, None
        except OSError as e:
            return False, e
    
    
    def confined_walk_opath(workspace, relative):
        """The proposed open_confined(): O_PATH anchor + O_PATH components."""
        anchor = os.open("/", O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)
        fd = anchor
        try:
            for comp in workspace.strip("/").split("/"):
                fd = os.open(comp, O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC, dir_fd=fd)
            parts = relative.strip("/").split("/")
            for comp in parts[:-1]:
                try:
                    fd = os.open(comp, O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC, dir_fd=fd)
                except FileNotFoundError:
                    os.mkdir(comp, 0o700, dir_fd=fd)   # mirrors the mkdirat branch
                    fd = os.open(comp, O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC, dir_fd=fd)
            # materialize the final parent as a real directory fd
            real = os.open(".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC, dir_fd=fd)
            return real
        finally:
            os.close(fd)
    
    
    def main():
        print(f"workspace: {WORKSPACE}")
    
        ok, err = try_open("/", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)
        print(f"open('/', O_RDONLY|O_DIRECTORY)  [current open_confined anchor]: "
              f"{'OK' if ok else f'FAIL errno={err.errno} ({err.strerror})'}")
    
        ok, err = try_open("/", O_PATH | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)
        print(f"open('/', O_PATH|O_DIRECTORY)    [proposed fix anchor]:        "
              f"{'OK' if ok else f'FAIL errno={err.errno} ({err.strerror})'}")
    
        try:
            real = confined_walk_opath(WORKSPACE, TARGET_REL)
        except OSError as e:
            print(f"confined walk (fix):             FAIL errno={e.errno} ({e.strerror})")
            return 1
        print("confined walk (fix):             OK")
    
        # full confined write + read through the fixed walk
        leaf = TARGET_REL.split("/")[-1]
        fd = os.open(leaf, os.O_RDWR | os.O_CREAT | os.O_EXCL | O_NOFOLLOW | O_CLOEXEC,
                     0o600, dir_fd=real)
        os.write(fd, b"probe")
        os.fsync(fd)
        os.close(fd)
        fd = os.open(leaf, os.O_RDONLY | O_NOFOLLOW | O_CLOEXEC, dir_fd=real)
        data = os.read(fd, 5)
        os.close(fd)
        os.unlink(leaf, dir_fd=real)
        os.fsync(real)
        os.close(real)
    
        if data != b"probe":
            print("confined write/read:             FAIL (content mismatch)")
            return 1
        print("confined write/read through fix: OK")
        return 0
    
    
    if __name__ == "__main__":
        sys.exit(main())

    Expected output on an affected device:

    workspace: /data/data/com.termux/files/home
    open('/', O_RDONLY|O_DIRECTORY)  [current open_confined anchor]: FAIL errno=13 (Permission denied)
    open('/', O_PATH|O_DIRECTORY)    [proposed fix anchor]:        OK
    confined walk (fix):             OK
    confined write/read through fix: OK
    
  3. Hmbown commented on Oct 15, 2026

    @Hmbown
    Collaborator

    🤖 This reply is from Claude Opus 5.5, an AI agent working on behalf of the Codewhale maintainers. It is posted through @Hmbown's account, but Hunter did not write it.

    Thank you @printf033 — the strace line, the SELinux domain readback and the standalone reproducer make this an unusually clean diagnosis. I confirmed on current main that WorkspaceFile::open_confined still anchors at open("/", O_RDONLY|O_DIRECTORY|…) (crates/tui/src/fleet/files.rs:122) and opens every intermediate component with the same flags, so a search-only domain fails before the walk reaches the workspace.

    Your proposed shape (O_PATH for the anchor and intermediate components, then reopening the final parent as a real directory fd for fsync/renameat/unlinkat) is the direction we'd take. Would you open it as a PR against main so it lands under your name? Two things to keep in mind: O_PATH exists only on Linux/Android, so the change needs to be cfg-gated with macOS/BSD keeping today's flags; and the reopen of the final parent should keep O_NOFOLLOW/O_CLOEXEC. Nothing is shipped yet. This is not in 0.10.2 until a PR lands and passes CI on all three OSes, and we can't verify on an Android device ourselves, so your on-device check of the PR build would be the acceptance evidence.

    — Written by Claude Opus 5.5 (AI agent) on behalf of the maintainers.

  4. moved this from Backlog to In progress in CodeWhaleon Oct 15, 2026
  5. moved this from In progress to Done in CodeWhaleon Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageNew external report awaiting maintainer triage; repro, logs and version output help

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions