diff --git a/AGENTS.md b/AGENTS.md index 707c164b..3ba043fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ 1. 现役 guest 用户数据由 App 私有目录持有,host backing 从 `Context.filesDir` 派生;`/data/adb/minis/rootfs` 是 Root-owned、可替换 runtime state。 2. Guest 使用设备实际 App UID/GID;进入 chroot 后必须清空 supplementary groups 与 Linux capabilities,禁止写死 `10000`。 -3. `DirectRootRunner` 只允许执行 App 构造的受控基础设施动作:rootfs、namespace、bind、chroot、必要探测/修复和受控 legacy migration。按上游权限模型,本地 Agent 可使用结构化 `root.shell`(tool basename + args)调用受信 Android system 工具;该能力必须 local-only、有参数/超时/输出/进程组边界,不接受 raw command、host 文件 API 或通用 Root RPC,MCP/远程调用方不可见。另外两个 Root 代办入口都是 guest CLI:`minis-apt`(仅 `update`/`install`/`remove` 纯包名,命令由 App 构造,会移除运行时必需包的 `remove` 被拒绝)和 `android-root-cli`(Android 系统工具的整理子命令,加 `exec` 原始 root shell;权限开关默认关闭)。两者详见 `docs/contracts/04-SECURITY-CONTRACT.md`。项目只走 Root:不接入 Shizuku 或其它特权代理。 +3. `DirectRootRunner` 只允许执行 App 构造的受控基础设施动作:rootfs、namespace、bind、chroot、必要探测/修复和受控 legacy migration。按上游权限模型,本地 Agent 可使用结构化 `root.shell`(tool basename + args)调用受信 Android system 工具;该能力必须 local-only、有参数/超时/输出/进程组边界,不接受 raw command、host 文件 API 或通用 Root RPC,MCP/远程调用方不可见。另外两个 Root 代办入口都是 guest CLI:`minis-apt`(仅 `update`/`install`/`remove` 纯包名,命令由 App 构造,会移除运行时必需包的 `remove` 被拒绝)和 `android-root-cli`(Android 系统工具的整理子命令,加 `exec` 原始 root shell;权限开关默认关闭)。两者详见 `docs/contracts/04-SECURITY-CONTRACT.md`。项目只走 Root:不接入 Shizuku 或其它特权代理。来自 Agent / guest CLI 的特权命令统一经 `ProtectedRoot`(设备完整性保护,Issue #182):拦会让手机无法开机或使用的操作,其余放开;App 自己构造的基础设施动作仍走 `DirectRootRunner`。 4. 网络代理和 Root/chroot 是不同概念。HTTP/CONNECT 代理协议本身不要求 Root;当前 helper 仅因 Android 出站 UID/VPN/BPF 兼容需求可由特权身份启动。不得把它扩展成命令、文件或通用 RPC 服务。 5. 产品运行时不恢复 PRoot/Alpine 双栈;不要为了兼容全局关闭 SELinux。 6. Session 执行必须保持对应 session workspace 语义,不能用全局 `/workspace` 绕过隔离。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 868cc5fb..ecb7ff09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This changelog tracks **Minis for Android** as an independently maintained Andro ## Unreleased +- Device protection (Settings > Permissions, on by default): privileged commands from the agent, `android-root-cli` and the Android tools can no longer write partitions or the system partitions, remove `/data/system`, `/data/misc`, `/metadata` or `/data/adb`, uninstall or disable core system apps, factory-reset or remove user 0. Nothing is asked: the agent gets `DEVICE_PROTECTED: : ` and tries another way. Everything else stays open. Refusals are listed under the switch. When `/data` is nearly full the app freezes its own terminals and refuses new bulk writes until space is freed. `android-root-cli partition read` exports a partition read-only. - Root only: Shizuku (and AXManager / Sui) support is removed; Root is the app's one privileged path. The code before this change is kept on the branch `archive/before-root-only`. - `android-shizuku-cli` is now `android-root-cli`: the same subcommands and JSON, run as root through `su`. Its Agent permission switch carries over. - The virtual screen runs in a root service (libsu) instead of a Shizuku UserService. Run its compatibility check once more to switch it back on. diff --git a/docs/contracts/04-SECURITY-CONTRACT.md b/docs/contracts/04-SECURITY-CONTRACT.md index fb81d5e1..164d5535 100644 --- a/docs/contracts/04-SECURITY-CONTRACT.md +++ b/docs/contracts/04-SECURITY-CONTRACT.md @@ -17,6 +17,7 @@ - `DirectRootRunner` 的内部脚本动作限于确实需要权限的 rootfs 探测/修复、namespace/bind/chroot、受控 legacy 数据迁移及同类明确基础设施需求;本地 Agent 的 `root.shell` 只走结构化 Android tool/argv 合同,不得被扩展成 raw shell、host 文件或通用 RPC。 - 例外之一:`minis-apt`(guest CLI,经 `AptOffloadHandler`)。guest 以 App UID 运行、无法使用 apt,因此由 App 代为在 rootfs 的一次性 mount namespace 内以 Root 执行 `apt-get update|install|remove`。Agent 只给出包名,命令全部由 App 构造(`AptCommandPolicy`):只接受 `^[a-z0-9][a-z0-9+.-]{0,62}$` 形式的纯包名(拒绝选项、版本/发行版指定、路径、shell 字符),每次最多 20 个;`remove` 先用 apt 的模拟结果检查,若会连带移除运行时必需的包(`UbuntuProvisioner.BASE_PACKAGES` 等)则在移除前拒绝;同一时间只运行一个;走与其它 guest CLI 相同的权限开关(`apt_cli`,设置里可关闭);只影响 Ubuntu 环境,不触及 Android 系统与宿主文件。它不是通用 Root 执行入口,不接受原始命令。 - 例外之二:`android-root-cli`(guest CLI,经 `RootCliOffloadHandler` 与 `RootProcess`)。它以 root 运行 Android 系统工具:`package`/`permission`/`activity`/`display`/`settings`/`user`/`network`/`input`/`notification`/`file`/`device` 等整理过的子命令(参数向量由 App 构造),外加 `exec`——把剩余参数拼成一条命令交给 root 的 `sh -c`。`exec` 是有意给本地 Agent 的原始 root shell:2026-10 起项目只走 Root,按维护者的决定给 Agent 尽量大的权限,只防止把手机弄坏;此前同一能力是经 Shizuku 的 `android-shizuku-cli`(Shizuku 以 Root 启动时同样是 uid 0)。约束:只能从 guest shell 调用;受 `root_cli` 权限开关控制,默认关闭;su 缺失或被拒时直接返回 `SERVICE_NOT_RUNNING` / `PERMISSION_DENIED`,不运行任何命令。设备完整性保护(拦截写分区、系统分区、`/data` 系统数据、卸载核心系统包等)尚未实现,见 Issue #182;远程 MCP 调用方经 `linux.shell` 也能到达它:这是维护者接受的设计——每次调用都要用户在手机上单独批准(一次性 confirm,与「全自动」模式无关,不会被它跳过),且 `root_cli` 开关默认关闭。未经批准的远程请求拿不到 root shell。 +- 设备完整性保护(Issue #182,默认开启,只有用户能在设置 → 权限里关闭):来自 Agent 或 guest CLI 的特权命令(`root.shell`、`android-root-cli` 全部子命令含 `exec`、`android.app` 等 Android 工具,即所有经 `PrivilegedCommandRunner` / `RootProcess.run` 的调用)统一经 `ProtectedRoot`。只拦"最坏结果不是重启能解决"的操作:写 `/dev/block`、写系统分区(`/system` `/system_ext` `/vendor` `/product` `/odm` `/apex`)、写 `/data/system*` `/data/misc*` `/data/vendor*` `/data/property` `/data/apex` `/data/unencrypted` `/metadata` `/data/adb`(`/data/adb/minis` 除外)、删除或改名 `/data` 等顶层节点、卸载/停用/清除/挂起/隐藏核心系统包、恢复出厂、删除用户 0、把 `device_provisioned` / `user_setup_complete` 置 0。读取、其他 app 数据、`/sdcard`、非核心预装应用、kill 进程、网络、sysctl、重启一律放开。被拦时不弹窗、不走一次性审批,固定返回 `DEVICE_PROTECTED: <类别>: <目标>` 与退出码 77,并写审计日志(设置页可见)。两道防线:`DeviceIntegrityPolicy` 在执行前按命令词判定(含 `sh -c` / `su -c` / `env` / `find -exec` 解包、`cd` 后的相对路径、`..`/`//`/大小写/`/proc/*/root` 归一化);`ProtectedView` 让命令在一次性 mount namespace 里运行(受保护目录只读 bind、`/dev/block` 换成空 `nodev` tmpfs、`su` 路径换成桩、bounding set 去掉 `CAP_SYS_ADMIN`/`CAP_MKNOD`/`CAP_SYS_MODULE`/`CAP_SYS_RAWIO`),变量、`$(…)`、脚本文件里的目标由它兜底。视图建不起来(缺 `unshare` 或 rootfs 里的 `setpriv`)时命令不执行,返回 `DEVICE_PROTECTION_UNAVAILABLE`,失败关闭。分区读取走单独的只读导出命令 `android-root-cli partition read`,由 App 构造 `dd`,不经视图。存储水位(`/data` 剩余低于 `max(3 GB, 5%)`):冻结 guest shell 进程组、拒绝新的批量写命令(`cp`/`dd`/`tar`/`unzip` 等,不含 `rm`),发通知,空间恢复 25% 余量后自动继续,不删除任何东西。App 自己构造的基础设施(`DirectRootRunner`:rootfs、namespace、bind、网络 helper、无障碍修复)不经此处。不防刻意绕过:原始 `service call` 直接发 binder 事务不受前端检查。 - 虚拟屏的 root 服务(`VirtualScreenRootService`,由 libsu 经 `su` 以 `app_process` 拉起)是专用 binder 接口:只暴露 `IVirtualScreenService` 里固定的虚拟屏操作(建屏、启动应用、点按输入、截图、帧流),不提供命令、文件或通用 RPC,也不监听网络或 socket;binder 只交回本 App。 - Guest shell 进入 chroot 后必须通过 `setpriv` 切到真实 App UID/GID,清空 supplementary groups 与 Linux capabilities。 - Root-provider identity 只是可用性信号;uid=0 不代表 SELinux、mount、namespace 或 capability 一定允许操作。 diff --git a/docs/contracts/06-CURRENT-GAPS.md b/docs/contracts/06-CURRENT-GAPS.md index ea6ca068..72da0142 100644 --- a/docs/contracts/06-CURRENT-GAPS.md +++ b/docs/contracts/06-CURRENT-GAPS.md @@ -216,7 +216,7 @@ config registry 暴露为 `prompt.custom`(带确认与审计回退)。内置 已知缺口: -- 设备完整性保护(Issue #182)尚未实现:`exec`、`file rm -r`、`package uninstall` 等目前没有任何保护线。 +- 设备完整性保护(Issue #182)已实现(见 04 安全合同),但见下一节:全部未在真机验证。 已接受的设计(不是缺口): @@ -236,3 +236,22 @@ config registry 暴露为 `prompt.custom`(带确认与审计回退)。内置 ## PR4 — 下个版本移除旧模型组 Room 表(暂缓) PR2/PR3 只把运行配置迁移到固定 Model Slots,并保留旧表作为迁移/回滚兼容边界;本阶段不删 `provider_model_groups` 表、不升级 Room schema。后续 PR4 才能在数据升级路径和真机备份恢复验收完成后删除该表并将 Room schema 从当前 v4 升至 v5。 + +## 设备完整性保护(Issue #182,2026-10-10) + +只有 JVM 单测、编译和 lint 的证据;`ProtectedView` 生成的脚本、mount namespace、能力裁剪、存储水位冻结都**没有在真机上跑过**,不得据此声称设备结论。需要在小米 24129PN74C / HyperOS(KernelSU)上确认: + +- `unshare -m` 在 `su` 起的进程里可用,`mount -o rprivate,bind / /`、`/dev/block` 换 tmpfs、对 `/data/adb`、`/apex` 等目录的只读 bind 与 `remount,bind,ro` 都能成功;任何一步失败会让特权命令返回 `DEVICE_PROTECTION_UNAVAILABLE`(失败关闭); +- rootfs 里的 `setpriv` 经 `ld-linux-aarch64.so.1 --library-path` 在 chroot 之外能运行,`--bounding-set` 去掉 `CAP_SYS_ADMIN` 后 `pm`/`cmd`/`settings`/`am`/`dumpsys`/`input`/`wm`/`appops` 与现有 `android-root-cli` 子命令没有回归; +- 视图里 `mount -o remount,rw /system`、`dd of=/dev/block/…`、`rm -rf /data/system` 确实失败,`su` 桩生效; +- KernelSU 的 `/data/adb/ksu/bin/su` 被桩覆盖后,视图外的 App 自身 `su` 不受影响; +- 存储水位:guest 写大文件逼近阈值时自动冻结、发通知,清理后恢复;冻结只作用于 `shells/shell-*.pid` 记录的进程组; +- 小米补充名单(`com.miui.securitycenter`、`com.lbe.security.miui`、`com.miui.home`、`com.xiaomi.xmsf`、`com.miui.system`)需真机核对,其余 OEM 没有补充名单。 + +已知残余: + +- 原始 `service call` 直接发 binder 事务,不经 `pm`/`cmd`/`settings`/`am` 前端,绕过核心包和设备状态检查。本保护防的是 Agent 手滑,不防刻意绕过。 +- 命令词判定看不穿变量、`$(…)`、脚本文件;这类只靠视图兜底,且视图只管路径类(分区、系统分区、`/data` 系统数据),管不到 binder 类(核心包、恢复出厂、用户 0)。 +- `/data/adb/minis` 因例外保持可写,`rm -rf` 它会毁掉 rootfs(可重装),不会让手机变砖。 +- `/apex`、`/system` 等目录在系统挂载为只读的设备上,视图的只读 bind 是第二道保险,不是第一道。 +- 分区读取(`partition read`)在视图之外以 root 执行,只读块设备,目标文件走与其它命令相同的写路径检查。 diff --git a/src/android/app/src/main/java/com/openminis/app/MinisApp.kt b/src/android/app/src/main/java/com/openminis/app/MinisApp.kt index 93a56f74..2ff493cf 100644 --- a/src/android/app/src/main/java/com/openminis/app/MinisApp.kt +++ b/src/android/app/src/main/java/com/openminis/app/MinisApp.kt @@ -538,6 +538,8 @@ class MinisApp : Application(), ImageLoaderFactory { // the process. Mirrors iOS ConfigRegistry.shared.registerBuiltinsIfNeeded(). com.openminis.app.config.MinisConfigPermissionStore.init(this) com.openminis.app.config.AutonomyStore.init(this) + com.openminis.app.integrity.DeviceProtectionStore.init(this) + com.openminis.app.integrity.StorageGuard.start(this) com.openminis.app.config.audit.ConfigAuditLog.init(this) com.openminis.app.config.ConfigRegistry.init( this, providerRepository, envVarRepository, chatRepository, diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/DeviceIntegrityPolicy.kt b/src/android/app/src/main/java/com/openminis/app/integrity/DeviceIntegrityPolicy.kt new file mode 100644 index 00000000..cf214a6e --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/integrity/DeviceIntegrityPolicy.kt @@ -0,0 +1,581 @@ +package com.openminis.app.integrity + +/** + * What the device-integrity protection (Issue #182) refuses to do, in pure Kotlin. + * + * The line is one question: can the worst outcome be fixed by a reboot? If so the operation is open. + * Only operations that can leave the phone unable to boot or unusable are refused, and a refusal is a + * fixed error ([Denial.message]) with no prompt: the agent reads it and tries another way. + * + * This class answers for the cases that can be recognised from a command's words. It is the first + * line, giving a clear `DEVICE_PROTECTED` error. The second line is the protected view + * ([ProtectedView]) that the command actually runs in: read-only bind mounts, no writable block + * nodes, no `CAP_SYS_ADMIN`. The view is what holds when a command hides its target in a variable, a + * `$(…)` or a script file; this class cannot see through those and does not pretend to. + * + * Out of scope on purpose: raw binder transactions (`service call`) skip the front ends checked here. + * The protection stops a slip, not a deliberate bypass (see `06-CURRENT-GAPS.md`). + */ +class DeviceIntegrityPolicy( + private val packages: CorePackageResolver = CorePackageResolver.StaticOnly, +) { + enum class Category(val label: String) { + PARTITION("partition"), + SYSTEM_PARTITION("system-partition"), + DATA_SYSTEM("data-system"), + CORE_PACKAGE("core-package"), + DEVICE_STATE("device-state"), + STORAGE("storage"), + } + + data class Denial(val category: Category, val target: String) { + /** The one error text every entry point returns. */ + val message: String get() = "DEVICE_PROTECTED: ${category.label}: $target" + } + + /** Check one tool with its arguments, e.g. `root.shell` or an `android-root-cli` front end. */ + fun checkArgv(tool: String, args: List): Denial? = Checker().simple(Simple(listOf(tool) + args, emptyList())) + + /** Check a shell script, as given to `android-root-cli exec`. */ + fun checkScript(script: String): Denial? = Checker().script(script, 0) + + /** Check a path that a structured operation will write to (a file push destination, say). */ + fun checkWritePath(path: String): Denial? = Checker().path(path, destructive = false) + + /** Check a path that a structured operation will delete or rename. */ + fun checkDeletePath(path: String): Denial? = Checker().path(path, destructive = true) + + /** Whether the package-changing operation may touch [packageName]. */ + fun checkPackageOperation(operation: String, packageName: String): Denial? = + if (packages.isCore(packageName)) Denial(Category.CORE_PACKAGE, "$operation $packageName") else null + + // ------------------------------------------------------------------------------------------ + + internal data class Simple(val words: List, val redirects: List) + + private inner class Checker { + var cwd: String = "/" + + fun script(text: String, depth: Int): Denial? { + if (depth > MAX_DEPTH) return null + for (command in ShellWords.split(text)) { + simpleAt(command, depth)?.let { return it } + } + return null + } + + fun simple(command: Simple): Denial? = simpleAt(command, 0) + + private fun simpleAt(command: Simple, depth: Int): Denial? { + for (target in command.redirects) { + path(target, destructive = false)?.let { return it } + } + var words = command.words + // Leading VAR=value assignments do not change which command runs. + while (words.isNotEmpty() && ASSIGNMENT.matches(words.first())) words = words.drop(1) + if (words.isEmpty()) return null + var name = baseName(words.first()) + var args = words.drop(1) + + // Unwrap launchers until the command that does the work is reached. + var guard = 0 + while (guard++ < MAX_DEPTH) { + when { + name in SHELLS || name == "su" -> { + val c = args.indexOfFirst { it == "-c" || (it.startsWith("-") && !it.startsWith("--") && it.endsWith("c") && it.length <= 4) } + if (c >= 0 && c + 1 < args.size) return script(args[c + 1], depth + 1) + return null + } + name in WRAPPERS -> { + val rest = unwrap(name, args) ?: return null + if (rest.isEmpty()) return null + name = baseName(rest.first()) + args = rest.drop(1) + } + else -> break + } + } + return command(name, args, depth) + } + + private fun command(name: String, args: List, depth: Int): Denial? { + val operands = args.filter { !it.startsWith("-") || it == "-" } + return when { + name == "cd" -> { + operands.firstOrNull()?.let { cwd = resolve(it) } + null + } + name in DESTRUCTIVE -> firstPathDenial(args, destructive = true) + name in WRITERS -> firstPathDenial(args, destructive = false) + name == "dd" -> args.firstNotNullOfOrNull { arg -> + if (arg.startsWith("of=")) path(arg.removePrefix("of="), destructive = false) else null + } + name == "cp" || name == "install" || name == "rsync" || name == "ln" -> copyDenial(args) + name == "mv" -> firstPathDenial(args, destructive = true) + name == "tee" -> firstPathDenial(args, destructive = false) + name == "sed" || name == "perl" -> if (args.any { it == "-i" || it.startsWith("-i") || it == "--in-place" }) { + firstPathDenial(args, destructive = false) + } else { + null + } + name == "find" -> findDenial(args, depth) + name == "mount" -> mountDenial(args) + name == "umount" -> null + name.startsWith("mkfs") || name in BLOCK_TOOLS -> blockToolDenial(args) + name == "recovery" -> if (args.any { it.contains("wipe", ignoreCase = true) }) { + Denial(Category.DEVICE_STATE, "recovery ${args.joinToString(" ")}".trim()) + } else { + null + } + name == "am" -> amDenial(args) + name == "pm" -> pmDenial(args) + name == "cmd" -> cmdDenial(args) + name == "settings" -> settingsDenial(args) + name == "wipe" -> Denial(Category.DEVICE_STATE, "wipe") + else -> null + } + } + + // ---- launchers ---- + + /** The command that a launcher runs, or null when its arguments are not a command we follow. */ + private fun unwrap(name: String, args: List): List? { + var rest = args + when (name) { + "env" -> { + rest = rest.dropWhile { it.startsWith("-") || ASSIGNMENT.matches(it) } + } + "timeout" -> { + rest = rest.dropWhile { it.startsWith("-") } + rest = rest.drop(1) // the duration + } + "nice", "ionice", "chrt", "taskset" -> { + rest = rest.dropWhile { it.startsWith("-") || it.toIntOrNull() != null } + } + "busybox", "toybox", "toolbox", "command", "exec", "time", "nohup", "setsid", "stdbuf", "xargs", "flock", "sudo", "doas" -> { + rest = rest.dropWhile { it.startsWith("-") } + if (name == "flock") rest = rest.drop(1) + } + } + return rest + } + + // ---- per-command rules ---- + + private fun firstPathDenial(args: List, destructive: Boolean): Denial? { + for (arg in args) { + if (arg.startsWith("-")) continue + path(arg.substringAfter('=', arg), destructive)?.let { return it } + } + return null + } + + private fun copyDenial(args: List): Denial? { + val target = args.indexOfFirst { it == "-t" || it.startsWith("--target-directory") } + if (target >= 0) { + val dir = if (args[target].contains('=')) args[target].substringAfter('=') else args.getOrNull(target + 1) + if (dir != null) path(dir, destructive = false)?.let { return it } + } + val operands = args.filter { !it.startsWith("-") } + return operands.lastOrNull()?.let { path(it, destructive = false) } + } + + private fun findDenial(args: List, depth: Int): Denial? { + val roots = args.takeWhile { !it.startsWith("-") && it != "(" && it != "!" } + if (args.any { it == "-delete" || it == "-fdelete" }) { + for (root in roots) path(root, destructive = true)?.let { return it } + } + var i = 0 + while (i < args.size) { + if (args[i] in setOf("-exec", "-execdir", "-ok", "-okdir")) { + val end = args.drop(i + 1).indexOfFirst { it == ";" || it == "+" || it == "\\;" } + val inner = if (end >= 0) args.subList(i + 1, i + 1 + end) else args.drop(i + 1) + // The words after `-exec` run with the found files; also guard the search roots. + if (inner.isNotEmpty()) { + val name = baseName(inner.first()) + val innerArgs = inner.drop(1) + if (name in DESTRUCTIVE || name in WRITERS || name == "mv" || name == "cp") { + for (root in roots) path(root, destructive = name !in WRITERS)?.let { return it } + } + command(name, innerArgs.filter { it != "{}" }, depth + 1)?.let { return it } + } + i += if (end >= 0) end + 2 else args.size + } else { + i++ + } + } + return null + } + + private fun mountDenial(args: List): Denial? { + val options = args.indexOf("-o").let { if (it >= 0) args.getOrNull(it + 1).orEmpty() else "" } + val remount = options.contains("remount") || args.any { it.contains("remount") } + val operands = args.filter { !it.startsWith("-") && it != options } + for (operand in operands) { + if (!operand.startsWith("/")) continue + val normalized = normalize(resolve(operand)) + if (remount && normalized == "/") return Denial(Category.SYSTEM_PARTITION, "remount /") + path(operand, destructive = false)?.let { return it } + if (remount && isSystemPartitionRoot(normalized)) return Denial(Category.SYSTEM_PARTITION, "remount $normalized") + } + return null + } + + private fun blockToolDenial(args: List): Denial? { + for (arg in args) { + if (!arg.startsWith("/")) continue + val n = normalize(resolve(arg)) + if (n.startsWith("/dev/block") || n.startsWith("/dev/mem") || n.startsWith("/dev/kmem")) { + return Denial(Category.PARTITION, n) + } + } + return null + } + + private fun amDenial(args: List): Denial? { + val hit = args.firstOrNull { arg -> FACTORY_RESET_ACTIONS.any { arg.contains(it, ignoreCase = true) } } + return hit?.let { Denial(Category.DEVICE_STATE, it) } + } + + private fun pmDenial(args: List): Denial? { + val verbAt = args.indexOfFirst { !it.startsWith("-") } + if (verbAt < 0) return null + return packageVerb(args[verbAt].lowercase(), args.drop(verbAt + 1)) + } + + private fun cmdDenial(args: List): Denial? { + val serviceAt = args.indexOfFirst { !it.startsWith("-") } + if (serviceAt < 0) return null + val after = args.drop(serviceAt + 1) + return when (args[serviceAt].lowercase()) { + "package" -> pmDenial(after) + "user" -> { + val verbAt = after.indexOfFirst { !it.startsWith("-") } + if (verbAt < 0) null else userVerb(after[verbAt].lowercase(), after.drop(verbAt + 1)) + } + "recovery" -> if (after.any { it.contains("wipe", ignoreCase = true) }) { + Denial(Category.DEVICE_STATE, "cmd recovery wipe") + } else { + null + } + else -> null + } + } + + private fun packageVerb(verb: String, rest: List): Denial? { + if (verb == "remove-user") return userVerb(verb, rest) + if (verb !in PACKAGE_VERBS) return null + val user = rest.windowed(2).firstOrNull { it[0] == "--user" }?.get(1) + for (token in rest) { + val pkg = token.substringBefore('/') + if (pkg != "android" && !PACKAGE_NAME.matches(pkg)) continue + if (packages.isCore(pkg)) { + return Denial(Category.CORE_PACKAGE, "$verb $pkg" + (user?.let { " (user $it)" } ?: "")) + } + } + return null + } + + private fun userVerb(verb: String, rest: List): Denial? { + if (verb != "remove-user" && verb != "remove" && verb != "rm") return null + val id = rest.lastOrNull { !it.startsWith("-") } + return if (id == "0") Denial(Category.DEVICE_STATE, "remove user 0") else null + } + + private fun settingsDenial(args: List): Denial? { + val verbAt = args.indexOfFirst { it == "put" || it == "delete" } + if (verbAt < 0) return null + val after = args.drop(verbAt + 1).filter { !it.startsWith("--") } + val key = after.getOrNull(1) ?: return null + if (key !in PROVISION_KEYS) return null + if (args[verbAt] == "delete") return Denial(Category.DEVICE_STATE, "settings delete $key") + return if (after.getOrNull(2) == "0") Denial(Category.DEVICE_STATE, "settings put $key 0") else null + } + + // ---- paths ---- + + fun path(raw: String, destructive: Boolean): Denial? { + if (raw.isEmpty()) return null + val glob = raw.indexOfFirst { it == '*' || it == '?' || it == '[' } + if (glob >= 0) return globDenial(raw, glob, destructive) + val normalized = normalize(resolve(raw)) + zone(normalized)?.let { return it } + if (destructive && normalized.lowercase() in PROTECTED_NODES) { + return Denial(Category.DATA_SYSTEM, normalized) + } + return null + } + + /** A glob is refused when anything it could match is protected. */ + private fun globDenial(raw: String, glob: Int, destructive: Boolean): Denial? { + val literal = raw.substring(0, glob) + var lit = normalize(resolve(literal)) + if (literal.endsWith("/") && !lit.endsWith("/")) lit += "/" + val lower = lit.lowercase() + zone(lit.trimEnd('/').ifEmpty { "/" })?.let { return it.copy(target = raw) } + if (ZONES.any { it.path.startsWith(lower) }) { + return Denial(ZONES.first { it.path.startsWith(lower) }.category, raw) + } + if (destructive && PROTECTED_NODES.any { it.startsWith(lower) }) return Denial(Category.DATA_SYSTEM, raw) + return null + } + + private fun zone(normalized: String): Denial? { + val lower = normalized.lowercase() + for (z in ZONES) { + if (underZone(lower, z)) { + if (z.path == "/data/adb" && (lower == MINIS_DIR || lower.startsWith("$MINIS_DIR/"))) return null + return Denial(z.category, normalized) + } + } + return null + } + + private fun underZone(lower: String, z: Zone): Boolean = + if (z.prefix) lower.startsWith(z.path) else lower == z.path || lower.startsWith(z.path + "/") + + private fun resolve(raw: String): String = if (raw.startsWith("/")) raw else "$cwd/$raw" + } + + private data class Zone(val path: String, val category: Category, val prefix: Boolean = false) + + companion object { + private const val MAX_DEPTH = 6 + private const val MINIS_DIR = "/data/adb/minis" + private val ASSIGNMENT = Regex("""[A-Za-z_][A-Za-z0-9_]*=.*""") + private val PACKAGE_NAME = Regex("""[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+""") + + private val ZONES = listOf( + Zone("/dev/block", Category.PARTITION), + Zone("/dev/mem", Category.PARTITION), + Zone("/dev/kmem", Category.PARTITION), + Zone("/system", Category.SYSTEM_PARTITION), + Zone("/system_ext", Category.SYSTEM_PARTITION), + Zone("/vendor", Category.SYSTEM_PARTITION), + Zone("/product", Category.SYSTEM_PARTITION), + Zone("/odm", Category.SYSTEM_PARTITION), + Zone("/apex", Category.SYSTEM_PARTITION), + Zone("/data/system", Category.DATA_SYSTEM, prefix = true), + Zone("/data/misc", Category.DATA_SYSTEM, prefix = true), + Zone("/data/vendor", Category.DATA_SYSTEM, prefix = true), + Zone("/data/property", Category.DATA_SYSTEM), + Zone("/data/apex", Category.DATA_SYSTEM), + Zone("/data/unencrypted", Category.DATA_SYSTEM), + Zone("/data/adb", Category.DATA_SYSTEM), + Zone("/metadata", Category.DATA_SYSTEM), + Zone("/cache/recovery", Category.DEVICE_STATE), + ) + + /** Top-level data nodes that may be written under but never removed or renamed themselves. */ + private val PROTECTED_NODES = setOf( + "/", "/data", "/data/data", "/data/user", "/data/user_de", "/data/app", "/data/media", + ) + + private val SYSTEM_PARTITION_ROOTS = setOf("/system", "/system_ext", "/vendor", "/product", "/odm", "/apex", "/") + private fun isSystemPartitionRoot(path: String) = path.lowercase() in SYSTEM_PARTITION_ROOTS + + private val SHELLS = setOf("sh", "bash", "mksh", "ash", "dash", "zsh", "ksh") + private val WRAPPERS = setOf( + "env", "nohup", "setsid", "nice", "ionice", "chrt", "taskset", "timeout", "time", "busybox", + "toybox", "toolbox", "command", "exec", "stdbuf", "xargs", "flock", "sudo", "doas", + ) + + private val DESTRUCTIVE = setOf("rm", "rmdir", "unlink", "shred") + private val WRITERS = setOf( + "truncate", "touch", "mkdir", "chmod", "chown", "chgrp", "chcon", "setfattr", "restorecon", + "mknod", "mkfifo", + ) + private val BLOCK_TOOLS = setOf( + "blkdiscard", "sgdisk", "sfdisk", "fdisk", "parted", "wipefs", "flash_erase", "mke2fs", "make_f2fs", + "fsck", "e2fsck", "fsck.f2fs", "tune2fs", "resize2fs", "resize.f2fs", "fastboot", "flash_image", + "erase_image", + ) + + private val FACTORY_RESET_ACTIONS = listOf("FACTORY_RESET", "MASTER_CLEAR", "wipe_data") + private val PROVISION_KEYS = setOf("device_provisioned", "user_setup_complete") + private val PACKAGE_VERBS = setOf( + "uninstall", "disable", "disable-user", "disable-until-used", "clear", "suspend", "hide", + ) + + private fun baseName(word: String): String = word.substringAfterLast('/').lowercase() + + /** Lexical normalisation: `//`, `.`, `..`, and `/proc//root` all resolve to the same path. */ + internal fun normalize(path: String): String { + val parts = ArrayDeque() + for (part in path.split('/')) { + when (part) { + "", "." -> {} + ".." -> if (parts.isNotEmpty()) parts.removeLast() + else -> parts.addLast(part) + } + } + // /proc/|self/root/… is the root directory seen through procfs. + if (parts.size >= 3 && parts[0] == "proc" && parts[2] == "root" && + (parts[1] == "self" || parts[1] == "thread-self" || parts[1].all { it.isDigit() }) + ) { + repeat(3) { parts.removeFirst() } + } + return "/" + parts.joinToString("/") + } + } +} + +/** Decides whether a package is one the phone needs to boot and be usable. */ +interface CorePackageResolver { + fun isCore(packageName: String): Boolean + + /** Rules that need no device: used on the host and as the floor on a device. */ + object StaticOnly : CorePackageResolver { + override fun isCore(packageName: String): Boolean = CorePackageRules.isStaticCore(packageName) + } +} + +object CorePackageRules { + /** Packages whose loss stops boot or the unlock screen on AOSP and the OEMs in use. */ + private val STATIC = setOf( + "android", + "com.android.settings", + "com.android.systemui", + "com.android.providers.settings", + "com.android.permissioncontroller", + "com.google.android.permissioncontroller", + "com.android.packageinstaller", + "com.google.android.packageinstaller", + "com.android.shell", + "com.android.phone", + "com.android.server.telecom", + "com.android.inputdevices", + "com.android.keychain", + "com.android.location.fused", + "com.android.providers.contacts", + "com.android.providers.telephony", + "com.android.providers.media.module", + "com.android.providers.downloads", + "com.android.externalstorage", + "com.android.mtp", + "com.android.nfc", + "com.android.bluetooth", + // Xiaomi / HyperOS; needs a device check (see 06-CURRENT-GAPS.md). + "com.miui.securitycenter", + "com.lbe.security.miui", + "com.miui.home", + "com.xiaomi.xmsf", + "com.miui.system", + // This app. + "llc.slacker.minis", + ) + + fun isStaticCore(packageName: String): Boolean = packageName in STATIC + + /** Facts about one installed package that the device resolver gathers. */ + data class Facts( + val packageName: String, + val persistent: Boolean = false, + val sharedUserId: String? = null, + val isCurrentHome: Boolean = false, + val isDefaultInputMethod: Boolean = false, + ) + + fun isCore(facts: Facts): Boolean = + isStaticCore(facts.packageName) || + facts.persistent || + facts.sharedUserId == "android.uid.system" || + facts.sharedUserId == "android.uid.phone" || + facts.isCurrentHome || + facts.isDefaultInputMethod +} + +/** + * Splits a shell script into simple commands without running anything. Quotes and backslashes are + * honoured; `;`, `&`, `|`, newlines, parentheses and braces end a command, so what is inside `$( )` or + * a subshell is checked as a command of its own. Redirection targets are kept apart from the words. + */ +internal object ShellWords { + fun split(script: String): List { + val commands = mutableListOf() + var words = mutableListOf() + var redirects = mutableListOf() + val current = StringBuilder() + var inWord = false + var pendingRedirect = false + + fun endWord() { + if (!inWord) return + val word = current.toString() + current.clear() + inWord = false + if (pendingRedirect) { + redirects += word + pendingRedirect = false + } else { + words += word + } + } + + fun endCommand() { + endWord() + pendingRedirect = false + if (words.isNotEmpty() || redirects.isNotEmpty()) { + commands += DeviceIntegrityPolicy.Simple(words, redirects) + } + words = mutableListOf() + redirects = mutableListOf() + } + + var i = 0 + while (i < script.length) { + val c = script[i] + when { + c == '\\' && i + 1 < script.length -> { + current.append(script[i + 1]); inWord = true; i += 2 + } + c == '\'' -> { + val end = script.indexOf('\'', i + 1).let { if (it < 0) script.length else it } + current.append(script, i + 1, end); inWord = true; i = end + 1 + } + c == '"' -> { + var j = i + 1 + while (j < script.length && script[j] != '"') { + if (script[j] == '\\' && j + 1 < script.length) j++ + current.append(script[j]); j++ + } + inWord = true; i = j + 1 + } + c == ' ' || c == '\t' -> { endWord(); i++ } + c == '\n' || c == ';' || c == '|' || c == '(' || c == ')' || c == '{' || c == '}' || c == '`' -> { + // `$(` starts a command; the `$` is left on the previous word, which is harmless. + endCommand(); i++ + } + c == '&' -> { + if (current.endsWith(">") || (i + 1 < script.length && script[i + 1] == '>')) { + // `&>file`: treat as a redirect operator. + endWord(); pendingRedirect = true + i += if (i + 1 < script.length && script[i + 1] == '>') 2 else 1 + if (i < script.length && script[i] == '>') i++ + } else { + endCommand(); i++ + } + } + c == '>' -> { + // `2>/dev/null` and `1>file`: a leading digit is the descriptor, not a word. + if (inWord && current.all { it.isDigit() }) { current.clear(); inWord = false } else endWord() + var j = i + 1 + if (j < script.length && (script[j] == '>' || script[j] == '|')) j++ + if (j < script.length && script[j] == '&') { + // `>&2`: duplicates a descriptor, no file. + j++ + while (j < script.length && script[j].isDigit()) j++ + pendingRedirect = false + } else { + pendingRedirect = true + } + i = j + } + c == '<' -> { endWord(); pendingRedirect = false; i++ } + else -> { current.append(c); inWord = true; i++ } + } + } + endCommand() + return commands + } +} diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/DeviceProtectionStore.kt b/src/android/app/src/main/java/com/openminis/app/integrity/DeviceProtectionStore.kt new file mode 100644 index 00000000..b230d6cc --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/integrity/DeviceProtectionStore.kt @@ -0,0 +1,40 @@ +package com.openminis.app.integrity + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Whether device-integrity protection (Issue #182) is on. It is on by default and stays on until the + * user turns it off in Settings → Permissions. + * + * Like `AutonomyStore`, nothing else can write it: there is no config field for it, the backup + * allowlist does not name its preference file, and the agent has no tool that reaches it. Until + * [init] has run the answer is "on". + */ +object DeviceProtectionStore { + private const val PREFS = "device_protection_prefs" + private const val KEY = "enabled" + private var prefs: SharedPreferences? = null + + private val _enabled = MutableStateFlow(true) + val enabled: StateFlow = _enabled.asStateFlow() + + val isEnabled: Boolean get() = _enabled.value + + /** Call once early, from MinisApp.onCreate. Idempotent. */ + fun init(context: Context) { + if (prefs != null) return + val p = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + prefs = p + _enabled.value = p.getBoolean(KEY, true) + } + + fun setEnabled(value: Boolean) { + prefs?.edit { putBoolean(KEY, value) } + _enabled.value = value + } +} diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/IntegrityAudit.kt b/src/android/app/src/main/java/com/openminis/app/integrity/IntegrityAudit.kt new file mode 100644 index 00000000..87c455db --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/integrity/IntegrityAudit.kt @@ -0,0 +1,39 @@ +package com.openminis.app.integrity + +import com.openminis.app.logging.AppLogger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Every refusal by the device-integrity protection, newest first, for the diagnostics list in + * Settings. Each one is also written to the app log, which survives a restart; this list does not. + */ +object IntegrityAudit { + private const val MAX_ENTRIES = 200 + private const val TAG = "DeviceIntegrity" + + data class Entry( + val timeMs: Long, + val entryPoint: String, + val sessionId: String?, + val category: String, + val target: String, + ) + + private val _entries = MutableStateFlow>(emptyList()) + val entries: StateFlow> = _entries.asStateFlow() + + fun record(entryPoint: String, sessionId: String?, denial: DeviceIntegrityPolicy.Denial, nowMs: Long = System.currentTimeMillis()) { + val entry = Entry(nowMs, entryPoint, sessionId, denial.category.label, denial.target.take(200)) + _entries.value = (listOf(entry) + _entries.value).take(MAX_ENTRIES) + // AppLogger is a no-op before init and on the host; the in-memory list is the record then. + runCatching { + AppLogger.warning(TAG, "blocked category=${entry.category} entry=$entryPoint session=${sessionId ?: "-"} target=${entry.target}") + } + } + + internal fun clearForTest() { + _entries.value = emptyList() + } +} diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/ProtectedRoot.kt b/src/android/app/src/main/java/com/openminis/app/integrity/ProtectedRoot.kt new file mode 100644 index 00000000..225704fe --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/integrity/ProtectedRoot.kt @@ -0,0 +1,104 @@ +package com.openminis.app.integrity + +import com.openminis.app.runtime.ubuntu.DirectRootRunner +import com.openminis.app.runtime.ubuntu.UbuntuPaths +import com.openminis.app.util.shellQuote +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * The single door for privileged commands that come from the agent or a guest CLI (Issue #182). + * `root.shell`, `android-root-cli` and the Android tools all call this instead of [DirectRootRunner]; + * infrastructure that the app builds itself (rootfs, namespaces, binds, the network helper, accessibility + * repair) keeps using [DirectRootRunner] directly and is not subject to the policy. + * + * With protection on, a command is first checked by [DeviceIntegrityPolicy] (a refusal returns + * `DEVICE_PROTECTED: : ` on stderr with [EXIT_DEVICE_PROTECTED], never a prompt) and + * then runs inside the [ProtectedView]. With protection off it runs as before. + */ +internal object ProtectedRoot { + /** Fixed exit code of a refused command (EX_NOPERM). */ + const val EXIT_DEVICE_PROTECTED = 77 + + @Volatile + internal var policy: DeviceIntegrityPolicy = DeviceIntegrityPolicy(RootCorePackageResolver) + + /** Replaceable for tests: runs the final script as Root. */ + @Volatile + internal var runner: suspend (String, Long) -> DirectRootRunner.Result = { script, timeout -> + DirectRootRunner.runScript(script, timeout) + } + + @Volatile + internal var enabled: () -> Boolean = { DeviceProtectionStore.isEnabled } + + @Volatile + internal var storageBlocksWrites: () -> Boolean = { StorageGuard.writesBlocked } + + @Volatile + internal var rootfs: String = UbuntuPaths.HOST_ROOTFS + + suspend fun runArgv( + entryPoint: String, + sessionId: String?, + argv: List, + timeoutMs: Long = 30_000L, + ): DirectRootRunner.Result { + require(argv.isNotEmpty()) { "argv must not be empty" } + if (enabled()) { + val denial = withContext(Dispatchers.IO) { + policy.checkArgv(argv.first(), argv.drop(1)) + ?: storageDenial(argv.first(), argv.drop(1).joinToString(" ")) + } + if (denial != null) return refuse(entryPoint, sessionId, denial) + } + return execute("exec " + argv.joinToString(" ") { shellQuote(it) }, timeoutMs) + } + + suspend fun runScript( + entryPoint: String, + sessionId: String?, + script: String, + timeoutMs: Long = 30_000L, + ): DirectRootRunner.Result { + if (enabled()) { + val denial = withContext(Dispatchers.IO) { + policy.checkScript(script) ?: storageDenial(script.trimStart().substringBefore(' '), script) + } + if (denial != null) return refuse(entryPoint, sessionId, denial) + } + return execute(script, timeoutMs) + } + + /** Check a path an operation will write to, without running anything (file push destination). */ + fun checkWrite(entryPoint: String, sessionId: String?, path: String): DeviceIntegrityPolicy.Denial? = + if (!enabled()) null else policy.checkWritePath(path)?.also { IntegrityAudit.record(entryPoint, sessionId, it) } + + /** Check a path an operation will delete or rename. */ + fun checkDelete(entryPoint: String, sessionId: String?, path: String): DeviceIntegrityPolicy.Denial? = + if (!enabled()) null else policy.checkDeletePath(path)?.also { IntegrityAudit.record(entryPoint, sessionId, it) } + + fun refusalResult(denial: DeviceIntegrityPolicy.Denial) = + DirectRootRunner.Result(EXIT_DEVICE_PROTECTED, "", denial.message) + + private fun refuse(entryPoint: String, sessionId: String?, denial: DeviceIntegrityPolicy.Denial): DirectRootRunner.Result { + IntegrityAudit.record(entryPoint, sessionId, denial) + return refusalResult(denial) + } + + private suspend fun execute(script: String, timeoutMs: Long): DirectRootRunner.Result { + val finalScript = if (enabled()) ProtectedView.wrap(script, rootfs) else script + return runner(finalScript, timeoutMs) + } + + /** While `/data` is nearly full, new commands that write bulk data wait (see [StorageGuard]). */ + private fun storageDenial(command: String, full: String): DeviceIntegrityPolicy.Denial? { + if (!storageBlocksWrites()) return null + val name = command.substringAfterLast('/').lowercase() + return if (StorageGuard.consumesSpace(name, full)) { + DeviceIntegrityPolicy.Denial(DeviceIntegrityPolicy.Category.STORAGE, "/data is nearly full; free space first") + } else { + null + } + } +} diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/ProtectedView.kt b/src/android/app/src/main/java/com/openminis/app/integrity/ProtectedView.kt new file mode 100644 index 00000000..73c8de08 --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/integrity/ProtectedView.kt @@ -0,0 +1,89 @@ +package com.openminis.app.integrity + +import com.openminis.app.util.shellQuote + +/** + * The protected view (Issue #182): the one place a privileged command actually runs when device + * protection is on. It is what holds when [DeviceIntegrityPolicy] cannot read the target out of the + * command (`sh -c "$(cat x)"`, a variable, a script file). + * + * The command runs in a throw-away mount namespace in which + * - the system partitions, `/data/system*`, `/data/misc*`, `/data/vendor*`, `/data/property`, + * `/data/apex`, `/data/unencrypted`, `/metadata` and `/data/adb` are read-only bind mounts + * (`/data/adb/minis` is bound back read-write), + * - `/dev/block` is replaced by an empty `nodev` tmpfs, so no partition node can be opened for + * writing. A read-only bind mount does not stop writes to device nodes, which is why the node + * directory is swapped out instead of remounted. Reading a partition goes through the separate + * `android-root-cli partition read` command, + * - every known `su` path is covered by a stub, so a protected command cannot start a fresh Root + * shell outside the view, + * and the shell is then started with `CAP_SYS_ADMIN`, `CAP_MKNOD`, `CAP_SYS_MODULE` and + * `CAP_SYS_RAWIO` removed from the bounding set, so none of this can be remounted, unmounted, + * re-created or loaded around. + * + * Android's toybox has no `setpriv`; the Ubuntu rootfs one is run through the rootfs dynamic loader + * without chroot. If any part of the view cannot be built the command does not run (exit 125, + * `DEVICE_PROTECTION_UNAVAILABLE`): protection fails closed, and the user can turn it off. + * + * Nothing here has been run on a device; see `06-CURRENT-GAPS.md`. + */ +internal object ProtectedView { + const val EXIT_UNAVAILABLE = 125 + const val UNAVAILABLE_PREFIX = "DEVICE_PROTECTION_UNAVAILABLE" + + /** Capabilities that could undo the view or write around it. Reboot and kill stay allowed. */ + internal const val DROPPED_CAPS = "-sys_admin,-mknod,-sys_module,-sys_rawio" + + /** Directories that become read-only. Globs are expanded by the shell inside the view. */ + internal val READ_ONLY_DIRS = listOf( + "/system", "/system_ext", "/vendor", "/product", "/odm", "/apex", "/metadata", + "/data/system*", "/data/misc*", "/data/vendor*", "/data/property", "/data/apex", + "/data/unencrypted", "/data/adb", + ) + + internal val SU_PATHS = listOf( + "/system/bin/su", "/system/xbin/su", "/sbin/su", "/su/bin/su", "/data/adb/ksu/bin/su", + "/debug_ramdisk/su", + ) + + private const val LIBDIR_REL = "usr/lib/aarch64-linux-gnu" + private const val LIBDIR2_REL = "lib/aarch64-linux-gnu" + private const val LOADER_REL = "usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1" + + /** + * Wrap [script] so it runs inside the view. [rootfs] is the Ubuntu rootfs on the host; its + * `setpriv` does the capability drop. [minisDir] stays writable under `/data/adb`. + */ + fun wrap(script: String, rootfs: String, minisDir: String = "/data/adb/minis"): String { + val inner = buildString { + append("fail() { echo \"$UNAVAILABLE_PREFIX: \$1\" >&2; exit $EXIT_UNAVAILABLE; }; ") + append("RF=${shellQuote(rootfs)}; ") + append("LOADER=\"\$RF/$LOADER_REL\"; ") + append("[ -x \"\$LOADER\" ] || fail 'rootfs loader not found'; ") + append("[ -x \"\$RF/usr/bin/setpriv\" ] || fail 'rootfs setpriv not found'; ") + append("/system/bin/mount -o rprivate,bind / / || fail 'cannot make mounts private'; ") + // Block nodes: swap the directory for an empty nodev tmpfs before anything else needs it. + append("/system/bin/mount -t tmpfs -o nodev,noexec,mode=755 tmpfs /dev/block || fail '/dev/block'; ") + // The su stub lives on a second tmpfs, so /dev/block stays empty. + append("mkdir -p /dev/.minis-guard && /system/bin/mount -t tmpfs -o nodev,mode=755 tmpfs /dev/.minis-guard || fail 'guard tmpfs'; ") + append("printf '%s\\n' '#!/system/bin/sh' 'echo \"DEVICE_PROTECTED: su is not available inside the protected view\" >&2' 'exit 126' > /dev/.minis-guard/su || fail 'su stub'; ") + append("chmod 755 /dev/.minis-guard/su; ") + append("for s in ${SU_PATHS.joinToString(" ") { shellQuote(it) }}; do ") + append("[ -e \"\$s\" ] && { /system/bin/mount -o bind /dev/.minis-guard/su \"\$s\" || fail \"su \$s\"; }; done; ") + append("for d in ${READ_ONLY_DIRS.joinToString(" ")}; do ") + append("[ -d \"\$d\" ] || continue; ") + append("/system/bin/mount -o bind \"\$d\" \"\$d\" && /system/bin/mount -o remount,bind,ro \"\$d\" || fail \"\$d\"; done; ") + append("if [ -d ${shellQuote(minisDir)} ]; then ") + append("/system/bin/mount -o bind ${shellQuote(minisDir)} ${shellQuote(minisDir)} && ") + append("/system/bin/mount -o remount,bind,rw ${shellQuote(minisDir)} || fail 'minis dir'; fi; ") + append("exec \"\$LOADER\" --library-path \"\$RF/$LIBDIR_REL:\$RF/$LIBDIR2_REL\" \"\$RF/usr/bin/setpriv\" ") + append("--bounding-set=$DROPPED_CAPS --inh-caps=$DROPPED_CAPS -- /system/bin/sh -c ${shellQuote(script)}") + } + return "if [ -x /system/bin/unshare ]; then exec /system/bin/unshare -m /system/bin/sh -c ${shellQuote(inner)}; " + + "else echo \"$UNAVAILABLE_PREFIX: unshare not found\" >&2; exit $EXIT_UNAVAILABLE; fi" + } + + /** True when a result is the view reporting that it could not be built. */ + fun isUnavailable(exitCode: Int, stderr: String): Boolean = + exitCode == EXIT_UNAVAILABLE && stderr.contains(UNAVAILABLE_PREFIX) +} diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/RootCorePackageResolver.kt b/src/android/app/src/main/java/com/openminis/app/integrity/RootCorePackageResolver.kt new file mode 100644 index 00000000..c0423435 --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/integrity/RootCorePackageResolver.kt @@ -0,0 +1,81 @@ +package com.openminis.app.integrity + +import com.openminis.app.runtime.ubuntu.DirectRootRunner +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import java.util.concurrent.ConcurrentHashMap + +/** + * Answers "is this package one the phone needs" from the device itself. The app has no + * QUERY_ALL_PACKAGES, so PackageManager cannot see arbitrary packages; the facts come from one fixed, + * read-only Root script (`dumpsys package`, the HOME resolver and the default input method). + * + * It fails closed: if the facts cannot be read, the package counts as core and the operation is + * refused. Answers are cached for a few seconds, because the default launcher and keyboard can change. + */ +object RootCorePackageResolver : CorePackageResolver { + private const val TIMEOUT_MS = 8_000L + private const val CACHE_MS = 5_000L + + private data class Cached(val core: Boolean, val atMs: Long) + + private val cache = ConcurrentHashMap() + + /** Replaceable for tests: runs the facts script and returns its stdout, or null on failure. */ + @Volatile + internal var factsRunner: (String) -> String? = { script -> + runBlocking(Dispatchers.IO) { + val r = DirectRootRunner.runScript(script, TIMEOUT_MS) + if (r.success) r.stdout else null + } + } + + @Volatile + internal var clock: () -> Long = System::currentTimeMillis + + override fun isCore(packageName: String): Boolean { + if (CorePackageRules.isStaticCore(packageName)) return true + if (!PACKAGE_NAME.matches(packageName)) return false + val now = clock() + cache[packageName]?.takeIf { now - it.atMs < CACHE_MS }?.let { return it.core } + val out = factsRunner(factsScript(packageName)) + val core = if (out == null) true else CorePackageFactsParser.isCore(packageName, out) + cache[packageName] = Cached(core, now) + return core + } + + internal fun clearCache() = cache.clear() + + private val PACKAGE_NAME = Regex("""[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+""") + + internal fun factsScript(packageName: String): String { + require(PACKAGE_NAME.matches(packageName)) { "not a package name" } + return "dumpsys package $packageName 2>/dev/null | grep -E '^ *(flags=|pkgFlags=|sharedUser=)' | head -6; " + + "echo __HOME__; " + + "cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.HOME 2>/dev/null | tail -1; " + + "echo __IME__; " + + "settings get secure default_input_method 2>/dev/null" + } +} + +/** Parses the output of [RootCorePackageResolver.factsScript]; pure. */ +internal object CorePackageFactsParser { + fun parse(packageName: String, output: String): CorePackageRules.Facts { + val home = output.substringAfter("__HOME__", "").substringBefore("__IME__").trim() + val ime = output.substringAfter("__IME__", "").trim() + val head = output.substringBefore("__HOME__") + val flagLines = head.lines().filter { it.trim().startsWith("flags=") || it.trim().startsWith("pkgFlags=") } + val persistent = flagLines.any { Regex("""\bPERSISTENT\b""").containsMatchIn(it) } + val shared = Regex("""sharedUser=SharedUserSetting\{[^}]*\s(android\.uid\.[a-z]+)/""").find(head)?.groupValues?.get(1) + return CorePackageRules.Facts( + packageName = packageName, + persistent = persistent, + sharedUserId = shared, + isCurrentHome = home.substringBefore('/') == packageName, + isDefaultInputMethod = ime.substringBefore('/') == packageName, + ) + } + + fun isCore(packageName: String, output: String): Boolean = + CorePackageRules.isCore(parse(packageName, output)) +} diff --git a/src/android/app/src/main/java/com/openminis/app/integrity/StorageGuard.kt b/src/android/app/src/main/java/com/openminis/app/integrity/StorageGuard.kt new file mode 100644 index 00000000..2aecbe5c --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/integrity/StorageGuard.kt @@ -0,0 +1,128 @@ +package com.openminis.app.integrity + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.StatFs +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.openminis.app.R +import com.openminis.app.logging.AppLogger +import com.openminis.app.runtime.ubuntu.DirectRootRunner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * The storage watermark of Issue #182: a full `/data` can stop the phone from booting, so when free + * space falls below the threshold the app stops *its own* sources of writes until space comes back. + * + * While paused it + * - freezes the guest shells' process groups (SIGSTOP) and resumes them (SIGCONT) afterwards, + * - refuses new privileged commands that bulk-write ([consumesSpace]) through [ProtectedRoot], and + * - posts one notification. + * It never deletes anything. Deleting (`rm`) stays allowed so the agent or the user can free space. + * + * Which `/data` is measured is the one the app can read via [StatFs]; the threshold is + * `max(3 GB, 5 %)` of the volume. Resuming needs 25 % more free space than pausing, so the state does + * not flap around the line. + */ +object StorageGuard { + const val MIN_THRESHOLD_BYTES = 3L * 1024 * 1024 * 1024 + private const val PERCENT = 5 + private const val POLL_MS = 30_000L + private const val CHANNEL_ID = "device_protection" + private const val NOTIFICATION_ID = 0x1824 + private const val TAG = "StorageGuard" + + @Volatile + var writesBlocked: Boolean = false + private set + + fun thresholdBytes(totalBytes: Long): Long = maxOf(MIN_THRESHOLD_BYTES, totalBytes / 100 * PERCENT) + + /** The next paused state given the current free space, with hysteresis. Pure. */ + fun nextPaused(paused: Boolean, freeBytes: Long, totalBytes: Long): Boolean { + val threshold = thresholdBytes(totalBytes) + return if (paused) freeBytes < threshold + threshold / 4 else freeBytes < threshold + } + + private val SPACE_COMMANDS = setOf( + "cp", "dd", "tar", "unzip", "fallocate", "truncate", "rsync", "install", "wget", "curl", "mkfile", + ) + + /** Whether a command is one that writes bulk data. `rm` and friends are never in this set. Pure. */ + fun consumesSpace(name: String, fullCommand: String): Boolean { + if (name !in SPACE_COMMANDS) return false + if (name == "dd" && Regex("""\bof=/dev/null\b""").containsMatchIn(fullCommand)) return false + if (name == "truncate" && !Regex("""(^|\s)(-s|--size)""").containsMatchIn(fullCommand)) return false + return true + } + + internal fun signalGuestShellsScript(signal: String): String { + require(signal == "STOP" || signal == "CONT") { "unsupported signal" } + return "DIR=${DirectRootRunner.ROOT_STATE_DIR}/shells; [ -d \"\$DIR\" ] || exit 0; " + + "for m in \"\$DIR\"/shell-*.pid; do [ -f \"\$m\" ] || continue; " + + "PID=\$(sed -n 1p \"\$m\" 2>/dev/null); case \"\$PID\" in ''|*[!0-9]*) continue;; esac; " + + "[ \"\$PID\" -gt 1 ] && kill -$signal -\$PID 2>/dev/null; done; exit 0" + } + + private var job: Job? = null + + /** Start polling. Idempotent; call from MinisApp.onCreate. */ + fun start(context: Context) { + if (job?.isActive == true) return + val app = context.applicationContext + job = CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + while (isActive) { + val stat = runCatching { StatFs("/data") }.getOrNull() + if (stat != null) { + val paused = nextPaused(writesBlocked, stat.availableBytes, stat.totalBytes) + if (paused != writesBlocked) { + writesBlocked = paused + onChanged(app, paused, stat.availableBytes) + } + } + delay(POLL_MS) + } + } + } + + private suspend fun onChanged(context: Context, paused: Boolean, freeBytes: Long) { + AppLogger.warning(TAG, if (paused) "paused guest writes: free=${freeBytes / (1024 * 1024)} MB" else "resumed guest writes") + // Freezing needs Root; if it is unavailable the command-level refusal still applies. + runCatching { DirectRootRunner.runScript(signalGuestShellsScript(if (paused) "STOP" else "CONT"), 10_000L) } + if (paused) notify(context, freeBytes) else NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) + } + + private fun notify(context: Context, freeBytes: Long) { + val manager = context.getSystemService(NotificationManager::class.java) ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, context.getString(R.string.device_protection_channel), NotificationManager.IMPORTANCE_HIGH), + ) + } + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(context.getString(R.string.device_storage_low_title)) + .setContentText(context.getString(R.string.device_storage_low_body, freeBytes / (1024 * 1024))) + .setOngoing(true) + .build() + // POST_NOTIFICATIONS is a runtime permission on Android 13+; without it the freeze and the command + // refusals still apply, only the notice is skipped. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return + } + runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) } + } +} diff --git a/src/android/app/src/main/java/com/openminis/app/offload/RootProcess.kt b/src/android/app/src/main/java/com/openminis/app/offload/RootProcess.kt index 55ca496f..95e34a15 100644 --- a/src/android/app/src/main/java/com/openminis/app/offload/RootProcess.kt +++ b/src/android/app/src/main/java/com/openminis/app/offload/RootProcess.kt @@ -1,5 +1,6 @@ package com.openminis.app.offload +import com.openminis.app.integrity.ProtectedRoot import com.openminis.app.runtime.ubuntu.DirectRootRunner import com.openminis.app.runtime.ubuntu.RootAccess import com.openminis.app.runtime.ubuntu.RootAccessStatus @@ -9,10 +10,14 @@ import kotlinx.coroutines.runBlocking /** * Run one argv as root and collect its output. * - * Used by `android-root-cli` (its handler runs synchronously on the offload IPC worker thread) and - * by the accessibility repairs. Every call goes through [DirectRootRunner], the same `su` launcher - * the Ubuntu runtime and `root.shell` use, so the app has one Root path. Arguments are quoted word - * by word; nothing is parsed by a shell unless the caller explicitly runs `sh -c`. + * Two doors, one `su` launcher ([DirectRootRunner]): + * - [run] is for `android-root-cli`, whose handler runs synchronously on the offload IPC worker + * thread and takes commands from the agent. It goes through [ProtectedRoot], so the device-integrity + * policy and the protected view apply (Issue #182). + * - [exec] and [runRaw] are for commands the app builds itself (accessibility repair, restricted + * settings, the su liveness check); they are not subject to the policy. + * Arguments are quoted word by word; nothing is parsed by a shell unless the caller explicitly runs + * `sh -c`. */ object RootProcess { const val DEFAULT_TIMEOUT_MS = 5_000L @@ -45,8 +50,19 @@ object RootProcess { fun unavailableReason(): String? = unavailable()?.message - /** Blocking form for synchronous callers; never call it on the main thread. */ + /** Session of the guest request being served on this thread, for the integrity audit. */ + val callerSession = ThreadLocal() + + /** Agent-originated command, subject to device protection. Never call it on the main thread. */ fun run(argv: Array, timeoutMs: Long = DEFAULT_TIMEOUT_MS): Result = + runBlocking(Dispatchers.IO) { + require(argv.isNotEmpty()) { "argv must not be empty" } + val r = ProtectedRoot.runArgv("android-root-cli", callerSession.get(), argv.toList(), timeoutMs) + toResult(r.exitCode, r.stdout, r.stderr, r.error) + } + + /** App-built command that must run outside the protected view (a liveness check, say). */ + fun runRaw(argv: Array, timeoutMs: Long = DEFAULT_TIMEOUT_MS): Result = runBlocking(Dispatchers.IO) { exec(argv.toList(), timeoutMs) } suspend fun exec(argv: List, timeoutMs: Long = DEFAULT_TIMEOUT_MS): Result { diff --git a/src/android/app/src/main/java/com/openminis/app/runtime/guest/RootCliOffloadHandler.kt b/src/android/app/src/main/java/com/openminis/app/runtime/guest/RootCliOffloadHandler.kt index 3e15f7e9..e4966c3b 100644 --- a/src/android/app/src/main/java/com/openminis/app/runtime/guest/RootCliOffloadHandler.kt +++ b/src/android/app/src/main/java/com/openminis/app/runtime/guest/RootCliOffloadHandler.kt @@ -42,6 +42,15 @@ import org.json.JSONObject class RootCliOffloadHandler(private val context: Context) : NativeOffloadHandler { override fun handle(request: NativeOffloadRequest): NativeOffloadResult { + RootProcess.callerSession.set(request.sessionId) + try { + return handleRequest(request) + } finally { + RootProcess.callerSession.remove() + } + } + + private fun handleRequest(request: NativeOffloadRequest): NativeOffloadResult { val argv = request.argv.drop(1) // Top-level help / version short-circuits — no Root needed. if (argv.isEmpty() || argv[0] == "--help" || argv[0] == "-h" || argv[0] == "help") { @@ -88,6 +97,7 @@ class RootCliOffloadHandler(private val context: Context) : NativeOffloadHandler "notification" -> handleNotification(rest, args) "file" -> handleFile(rest, args) "device" -> handleDevice(rest, args) + "partition" -> handlePartition(rest, args) "exec" -> handleExec(rest, args) else -> NativeOffloadResult( 2, @@ -112,12 +122,13 @@ class RootCliOffloadHandler(private val context: Context) : NativeOffloadHandler val data = JSONObject() .put("authorized", RootProcess.isGranted()) .put("available", reason == null) + .put("deviceProtection", com.openminis.app.integrity.DeviceProtectionStore.isEnabled) if (reason != null) data.put("reason", reason) okEnvelope(data, args) } // Asks su now: the answer is the uid the command actually ran as. "ping" -> { - val r = RootProcess.run(arrayOf("id", "-u")) + val r = RootProcess.runRaw(arrayOf("id", "-u")) if (r.exitCode == 0 && r.stdout.trim() == "0") { ok("OK root (uid=0)\n") } else { @@ -930,6 +941,44 @@ class RootCliOffloadHandler(private val context: Context) : NativeOffloadHandler return ok(limited + "\n") } + // ─── Group: partition (read-only export) ─────────────────────────────── + /** + * Partitions are write-protected for every other command (the protected view has no block nodes), + * so a read-only export lives here: the app builds `dd if=/dev/block/by-name/ of=` + * itself, the name is a plain identifier, and the destination goes through the same write check + * as any other command. This runs outside the protected view and only ever reads the block device. + */ + private fun handlePartition(rest: List, args: OffloadArgs): NativeOffloadResult { + val sub = rest.firstOrNull() ?: return ok(PARTITION_HELP) + return when (sub) { + "list" -> { + val r = RootProcess.runRaw(arrayOf("ls", "-1", "/dev/block/by-name"), timeoutMs = 5_000) + if (r.exitCode == 0) ok(r.stdout + "\n") else errEnvelope("OPERATION_FAILED", failMessage(r), args) + } + "read" -> partitionRead(args.positional.getOrNull(1), args.positional.getOrNull(2), args) + "help", "--help", "-h" -> ok(PARTITION_HELP) + else -> NativeOffloadResult(2, "partition: unknown subcommand '$sub'\n$PARTITION_HELP") + } + } + + private fun partitionRead(name: String?, dest: String?, args: OffloadArgs): NativeOffloadResult { + if (name.isNullOrBlank() || dest.isNullOrBlank()) { + return errEnvelope("INVALID_ARGS", "partition read ", args) + } + if (!PARTITION_NAME.matches(name)) return errEnvelope("INVALID_ARGS", "invalid partition name: $name", args) + if (!dest.startsWith("/") || dest.contains('\u0000')) { + return errEnvelope("INVALID_ARGS", "dest must be an absolute path", args) + } + com.openminis.app.integrity.ProtectedRoot.checkWrite("android-root-cli", RootProcess.callerSession.get(), dest) + ?.let { return errEnvelope("DEVICE_PROTECTED", it.message, args) } + val r = RootProcess.runRaw( + arrayOf("dd", "if=/dev/block/by-name/$name", "of=$dest", "bs=1048576"), + timeoutMs = args.getInt("timeout-ms")?.toLong() ?: 600_000L, + ) + return if (r.exitCode == 0) okEnvelope(JSONObject().put("partition", name).put("dest", dest), args) + else errEnvelope("OPERATION_FAILED", failMessage(r), args) + } + // ─── Group: exec (raw shell passthrough) ─────────────────────────────── /** * T341: wildcard fallback. Curated subcommands cover the well-known @@ -1071,12 +1120,14 @@ class RootCliOffloadHandler(private val context: Context) : NativeOffloadHandler } private fun errEnvelope(code: String, message: String, args: OffloadArgs): NativeOffloadResult { + // A command refused by device protection carries its own fixed text; surface it as its own code. + val effectiveCode = if (message.startsWith("DEVICE_PROTECTED")) "DEVICE_PROTECTED" else code val obj = JSONObject().put("ok", false).put( "error", - JSONObject().put("code", code).put("message", message), + JSONObject().put("code", effectiveCode).put("message", message), ) val body = OffloadOutput.formatBody(obj.toString(2), args) - AppLogger.warning(TAG, "$code: $message") + AppLogger.warning(TAG, "$effectiveCode: $message") return NativeOffloadResult(1, body + "\n") } @@ -1130,8 +1181,16 @@ Groups: notification list / dismiss / channel file ls / pull / push / rm device info / battery / usage + partition list / read (read-only export of a partition to a file) service status / ping +Device protection (on by default): commands that could leave the phone +unable to boot are refused with `DEVICE_PROTECTED: : ` +and exit code 77 — writing partitions or /system, /data/system|misc|vendor, +/metadata, /data/adb (except /data/adb/minis), uninstalling or disabling core +system apps, factory reset, removing user 0. Nothing is asked; use another +approach. Everything else is open. + Common flags: --format json|text|csv output format (default text) --compact single-line JSON @@ -1159,6 +1218,8 @@ Examples: android-root-cli exec "logcat -d -t 200 | grep MyTag" Notes: + - Runs in the device-protection view: partitions, /system and /data system + state are read-only there; a blocked command reports DEVICE_PROTECTED. - Caller owns quoting/escaping. Metacharacters (; | > backticks) work. - Default timeout 30s; override via --timeout-ms. - Prefer curated subcommands when they exist — they emit structured JSON. @@ -1271,6 +1332,18 @@ Usage: android-root-cli file rm [-r] """ + private val PARTITION_NAME = Regex("""[A-Za-z0-9_.-]{1,64}""") + + private const val PARTITION_HELP = """partition — read-only partition export. + +Usage: + android-root-cli partition list + android-root-cli partition read [--timeout-ms N] + +Copies /dev/block/by-name/ to (an absolute path). Partitions can +be read, never written: every other command runs without block-device nodes. +""" + private const val DEVICE_HELP = """device — device state. Usage: diff --git a/src/android/app/src/main/java/com/openminis/app/tools/android/PrivilegedCommandRunner.kt b/src/android/app/src/main/java/com/openminis/app/tools/android/PrivilegedCommandRunner.kt index 9a59b325..5e6ca453 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/android/PrivilegedCommandRunner.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/android/PrivilegedCommandRunner.kt @@ -2,6 +2,7 @@ package com.openminis.app.tools.android import android.content.Context import android.util.Log +import com.openminis.app.integrity.ProtectedRoot import com.openminis.app.runtime.ubuntu.DirectRootRunner import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -419,6 +420,7 @@ object PrivilegedCommandRunner { risk: CommandRisk = CommandRisk.READ_ONLY, timeoutMs: Long = 30_000L, rootOnly: Boolean = false, + entryPoint: String = "android-tool", ): AndroidCommandResult { require(argv.isNotEmpty()) { "privileged command argv must not be empty" } val tool = argv.first() @@ -447,7 +449,9 @@ object PrivilegedCommandRunner { "tool=$tool session=$sessionId", ) val result = try { - DirectRootRunner.runArgv(listOf(resolvedTool) + commandArgs, timeoutMs) + // Every agent-facing privileged command passes the device-integrity policy and runs in the + // protected view (Issue #182). Infrastructure the app builds itself uses DirectRootRunner. + ProtectedRoot.runArgv(entryPoint, sessionId, listOf(resolvedTool) + commandArgs, timeoutMs) } catch (cancelled: CancellationException) { throw cancelled } diff --git a/src/android/app/src/main/java/com/openminis/app/tools/runtime/RootShellHandler.kt b/src/android/app/src/main/java/com/openminis/app/tools/runtime/RootShellHandler.kt index bbf65786..67a5ee41 100644 --- a/src/android/app/src/main/java/com/openminis/app/tools/runtime/RootShellHandler.kt +++ b/src/android/app/src/main/java/com/openminis/app/tools/runtime/RootShellHandler.kt @@ -76,6 +76,7 @@ class RootShellHandler : ToolHandler { risk = PrivilegedCommandRisk.classify(tool, args), timeoutMs = timeout, rootOnly = true, + entryPoint = "root.shell", ) if (!response.success) { val detail = response.unavailableReason ?: response.stderr.ifBlank { "Root execution failed" } diff --git a/src/android/app/src/main/java/com/openminis/app/ui/settings/OffloadPermissionScreen.kt b/src/android/app/src/main/java/com/openminis/app/ui/settings/OffloadPermissionScreen.kt index 303920a4..51454915 100644 --- a/src/android/app/src/main/java/com/openminis/app/ui/settings/OffloadPermissionScreen.kt +++ b/src/android/app/src/main/java/com/openminis/app/ui/settings/OffloadPermissionScreen.kt @@ -95,6 +95,32 @@ fun OffloadPermissionScreen( ) } + // Device protection (Issue #182): only the user switches this, from here. + val protectionOn by com.openminis.app.integrity.DeviceProtectionStore.enabled.collectAsState() + val blocked by com.openminis.app.integrity.IntegrityAudit.entries.collectAsState() + SettingsSection(header = stringResource(R.string.perm_section_device_protection)) { + SettingsSwitchRow( + title = stringResource(R.string.perm_device_protection_title), + subtitle = stringResource(R.string.perm_device_protection_sub), + checked = protectionOn, + onCheckedChange = { com.openminis.app.integrity.DeviceProtectionStore.setEnabled(it) }, + showDivider = protectionOn, + ) + if (protectionOn) { + val recent = blocked.take(5) + SettingsRow( + title = stringResource(R.string.perm_device_protection_blocked), + subtitle = if (recent.isEmpty()) { + stringResource(R.string.perm_device_protection_none) + } else { + recent.joinToString("\n") { "${it.category}: ${it.target} (${it.entryPoint})" } + }, + showChevron = false, + showDivider = false, + ) + } + } + SettingsSection( header = stringResource(R.string.perm_section_config_tool), ) { diff --git a/src/android/app/src/main/res/values-de/strings.xml b/src/android/app/src/main/res/values-de/strings.xml index ec96f6a0..9c3360d2 100644 --- a/src/android/app/src/main/res/values-de/strings.xml +++ b/src/android/app/src/main/res/values-de/strings.xml @@ -306,6 +306,14 @@ Konfigurationstool Autonomie des Agenten + Geräteschutz + Geräteschutz + Blockiert nur, was das Gerät unbrauchbar machen oder am Starten hindern könnte: Schreiben auf Partitionen und Systemdateien, Kern-Systemapps, Zurücksetzen auf Werkseinstellungen. Es wird nichts abgefragt: Der Agent erhält einen Fehler und versucht es anders. Alles andere bleibt offen. + Zuletzt blockiert + Noch nichts blockiert + Geräteschutz + Speicher knapp — Minis hat Schreibvorgänge pausiert + Auf /data sind nur noch %1$d MB frei. Terminals und Hintergrundjobs sind pausiert, es wird nichts gelöscht. Sie laufen automatisch weiter, sobald Platz frei ist. Fragen Jede Änderung der Einstellungen durch den Agenten bestätigen. Intelligent diff --git a/src/android/app/src/main/res/values-fr/strings.xml b/src/android/app/src/main/res/values-fr/strings.xml index 3d50bdd6..94d52a40 100644 --- a/src/android/app/src/main/res/values-fr/strings.xml +++ b/src/android/app/src/main/res/values-fr/strings.xml @@ -306,6 +306,14 @@ Outil de configuration Autonomie de l\'agent + Protection de l\'appareil + Protection de l\'appareil + Bloque uniquement ce qui pourrait empêcher le téléphone de démarrer ou le rendre inutilisable : écriture sur les partitions et les fichiers système, applications système essentielles, réinitialisation d\'usine. Aucune confirmation : l\'agent reçoit une erreur et essaie autrement. Tout le reste reste ouvert. + Bloqués récemment + Rien n\'a été bloqué + Protection de l\'appareil + Stockage presque plein : Minis a suspendu ses écritures + Il ne reste que %1$d Mo sur /data. Les terminaux et les tâches en arrière-plan sont suspendus, rien n\'est supprimé. Ils reprennent automatiquement dès que de l\'espace est libéré. Demander Confirmer chaque modification des réglages par l\'agent. Intelligent diff --git a/src/android/app/src/main/res/values-ja/strings.xml b/src/android/app/src/main/res/values-ja/strings.xml index c8ba6c1d..483a1854 100644 --- a/src/android/app/src/main/res/values-ja/strings.xml +++ b/src/android/app/src/main/res/values-ja/strings.xml @@ -306,6 +306,14 @@ 設定ツール エージェントの自律レベル + デバイス保護 + デバイス保護 + 端末が起動できなくなる、または使えなくなる操作だけを止めます:パーティションやシステムファイルへの書き込み、コアシステムアプリの削除・無効化、初期化。確認は出さず、エージェントにはエラーが返り、別の方法を試します。それ以外は制限しません。 + 最近ブロックされた操作 + ブロックされた操作はまだありません + デバイス保護 + 空き容量が少ないため、Minisの書き込みを一時停止しました + /data の空きは %1$d MB のみです。ターミナルとバックグラウンドジョブを一時停止しました。ファイルは削除しません。空き容量が戻ると自動で再開します。 確認する エージェントによる設定変更をすべて確認します。 スマート diff --git a/src/android/app/src/main/res/values-ko/strings.xml b/src/android/app/src/main/res/values-ko/strings.xml index 817054d8..1e1951da 100644 --- a/src/android/app/src/main/res/values-ko/strings.xml +++ b/src/android/app/src/main/res/values-ko/strings.xml @@ -306,6 +306,14 @@ 설정 도구 에이전트 자율 수준 + 기기 보호 + 기기 보호 + 기기가 부팅되지 않거나 사용할 수 없게 되는 작업만 막습니다: 파티션·시스템 파일 쓰기, 핵심 시스템 앱 삭제·비활성화, 초기화. 확인 창 없이 에이전트에 오류가 전달되고 다른 방법을 시도합니다. 그 밖의 작업은 제한하지 않습니다. + 최근 차단된 작업 + 차단 기록이 없습니다 + 기기 보호 + 저장 공간이 부족하여 Minis의 쓰기를 일시 중지했습니다 + /data에 %1$d MB만 남았습니다. 터미널과 백그라운드 작업을 일시 중지했으며 파일은 삭제하지 않습니다. 공간이 확보되면 자동으로 재개됩니다. 묻기 에이전트가 설정을 바꿀 때마다 확인합니다. 스마트 diff --git a/src/android/app/src/main/res/values-ru/strings.xml b/src/android/app/src/main/res/values-ru/strings.xml index 424719d1..172836f4 100644 --- a/src/android/app/src/main/res/values-ru/strings.xml +++ b/src/android/app/src/main/res/values-ru/strings.xml @@ -531,6 +531,14 @@ Сбросить Инструмент настройки Автономность агента + Защита устройства + Защита устройства + Блокирует только то, что может помешать загрузке телефона или сделать его непригодным: запись в разделы и системные файлы, основные системные приложения, сброс до заводских настроек. Запросов нет: агент получает ошибку и пробует иначе. Всё остальное остаётся открытым. + Недавно заблокировано + Пока ничего не блокировалось + Защита устройства + Мало места — Minis приостановил запись + На /data осталось всего %1$d МБ. Терминалы и фоновые задачи приостановлены, ничего не удаляется. Они продолжатся автоматически, когда место освободится. Спрашивать Подтверждать каждое изменение настроек агентом. Умный режим diff --git a/src/android/app/src/main/res/values-zh-rTW/strings.xml b/src/android/app/src/main/res/values-zh-rTW/strings.xml index 91647b1c..b4bf0d9a 100644 --- a/src/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/src/android/app/src/main/res/values-zh-rTW/strings.xml @@ -1417,6 +1417,14 @@ 重置 配置工具 Agent 自主程度 + 裝置保護 + 裝置保護 + 只攔會讓手機無法開機或無法使用的操作:寫入分割區和系統檔案、解除安裝或停用核心系統應用程式、恢復原廠設定。不彈窗,Agent 直接收到錯誤並換個辦法;其他操作不受限。 + 最近攔截 + 尚無攔截紀錄 + 裝置保護 + 儲存空間不足,Minis 已暫停寫入 + /data 只剩 %1$d MB。終端機和背景任務已暫停,不會刪除任何檔案,釋出空間後自動繼續。 詢問 Agent 每次修改設定都要你確認。 智慧 diff --git a/src/android/app/src/main/res/values-zh/strings.xml b/src/android/app/src/main/res/values-zh/strings.xml index 05e2225d..f53e2455 100644 --- a/src/android/app/src/main/res/values-zh/strings.xml +++ b/src/android/app/src/main/res/values-zh/strings.xml @@ -400,6 +400,14 @@ 配置工具 Agent 自主程度 + 设备保护 + 设备保护 + 只拦会让手机无法开机或无法使用的操作:写分区和系统文件、卸载或停用核心系统应用、恢复出厂。不弹窗,Agent 直接收到错误并换办法;其他操作不受限。 + 最近被拦截 + 还没有拦截记录 + 设备保护 + 存储空间不足,Minis 已暂停写入 + /data 只剩 %1$d MB。终端和后台任务已暂停,不会删除任何文件,释放空间后自动继续。 询问 Agent 每次修改设置都要你确认。 智能 diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index bbe36102..39efa3d8 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -766,6 +766,14 @@ Configuration Tool Agent autonomy + Device protection + Device protection + Blocks only what could leave the phone unable to boot or unusable: writes to partitions and system files, core system apps, factory reset. Nothing is asked: the agent gets an error and tries another way. Everything else stays open. + Recently blocked + Nothing blocked yet + Device protection + Storage is low — Minis paused its writes + Only %1$d MB left on /data. Terminals and background jobs are paused and nothing is deleted. They resume automatically once space is freed. Ask Confirm every change the agent makes to settings. Smart diff --git a/src/android/app/src/test/java/com/openminis/app/integrity/DeviceIntegrityPolicyTest.kt b/src/android/app/src/test/java/com/openminis/app/integrity/DeviceIntegrityPolicyTest.kt new file mode 100644 index 00000000..a4870ccc --- /dev/null +++ b/src/android/app/src/test/java/com/openminis/app/integrity/DeviceIntegrityPolicyTest.kt @@ -0,0 +1,179 @@ +package com.openminis.app.integrity + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class DeviceIntegrityPolicyTest { + private val home = "com.example.launcher" + private val ime = "com.example.keyboard" + private val policy = DeviceIntegrityPolicy(object : CorePackageResolver { + override fun isCore(packageName: String) = + CorePackageRules.isStaticCore(packageName) || packageName == home || packageName == ime + }) + + private fun argv(vararg w: String) = policy.checkArgv(w.first(), w.drop(1)) + private fun script(s: String) = policy.checkScript(s) + + private fun assertDenied(d: DeviceIntegrityPolicy.Denial?, category: DeviceIntegrityPolicy.Category) { + assertNotNull("expected DEVICE_PROTECTED", d) + assertEquals(category, d!!.category) + check(d.message.startsWith("DEVICE_PROTECTED: ${category.label}: ")) + } + + // ---- root.shell (argv) denials ---- + + @Test fun `dd to a block device is refused`() = + assertDenied(argv("dd", "if=/dev/zero", "of=/dev/block/by-name/boot"), DeviceIntegrityPolicy.Category.PARTITION) + + @Test fun `rm of data system is refused`() = + assertDenied(argv("rm", "-rf", "/data/system"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + + @Test fun `remount of system is refused`() = + assertDenied(argv("mount", "-o", "remount,rw", "/system"), DeviceIntegrityPolicy.Category.SYSTEM_PARTITION) + + @Test fun `uninstalling systemui is refused`() = + assertDenied(argv("pm", "uninstall", "com.android.systemui"), DeviceIntegrityPolicy.Category.CORE_PACKAGE) + + @Test fun `clearing provisioning is refused`() { + assertDenied(argv("settings", "put", "global", "device_provisioned", "0"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + assertDenied(argv("settings", "put", "secure", "user_setup_complete", "0"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + assertDenied(argv("settings", "delete", "global", "device_provisioned"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + } + + @Test fun `provisioning set to one is fine`() = + assertNull(argv("settings", "put", "global", "device_provisioned", "1")) + + // ---- exec (script) denials ---- + + @Test fun `exec rm metadata glob is refused`() = + assertDenied(script("rm -rf /metadata/*"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + + @Test fun `exec redirect into a block device is refused`() = + assertDenied(script("echo x > /dev/block/sda"), DeviceIntegrityPolicy.Category.PARTITION) + + @Test fun `exec through sh -c is followed`() = + assertDenied(script("sh -c 'rm -rf /data/system'"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + + @Test fun `exec through su -c and env is followed`() { + assertDenied(script("su -c 'rm -rf /data/misc'"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(script("env FOO=1 rm -rf /data/vendor"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(script("busybox rm -rf /data/system"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + } + + @Test fun `exec chained commands each get checked`() = + assertDenied(script("ls /sdcard && rm -rf /data/system_ce; echo done"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + + @Test fun `exec find delete and exec are followed`() { + assertDenied(script("find /data/system -delete"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(script("find /data/misc -name '*.x' -exec rm {} ;"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + } + + @Test fun `cd then relative delete is resolved`() = + assertDenied(script("cd /data/system && rm -rf *"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + + @Test fun `file rm -r of data adb modules is refused`() = + assertDenied(policy.checkDeletePath("/data/adb/modules"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + + // ---- packages and device state ---- + + @Test fun `disabling the current launcher is refused`() = + assertDenied(argv("pm", "disable-user", "--user", "0", home), DeviceIntegrityPolicy.Category.CORE_PACKAGE) + + @Test fun `package operations through cmd package are refused`() { + assertDenied(argv("cmd", "package", "uninstall", "-k", "--user", "0", ime), DeviceIntegrityPolicy.Category.CORE_PACKAGE) + assertDenied(argv("pm", "clear", "com.android.settings"), DeviceIntegrityPolicy.Category.CORE_PACKAGE) + assertDenied(argv("pm", "suspend", "com.android.systemui"), DeviceIntegrityPolicy.Category.CORE_PACKAGE) + assertDenied(argv("pm", "hide", "android"), DeviceIntegrityPolicy.Category.CORE_PACKAGE) + } + + @Test fun `component names reduce to their package`() = + assertDenied(argv("pm", "disable", "com.android.systemui/.SystemUIService"), DeviceIntegrityPolicy.Category.CORE_PACKAGE) + + @Test fun `removing user zero is refused`() { + assertDenied(argv("pm", "remove-user", "0"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + assertDenied(argv("cmd", "user", "remove", "0"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + } + + @Test fun `removing another user is fine`() = assertNull(argv("pm", "remove-user", "10")) + + @Test fun `factory reset broadcasts and recovery wipe are refused`() { + assertDenied(argv("am", "broadcast", "-a", "android.intent.action.FACTORY_RESET"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + assertDenied(argv("am", "broadcast", "-a", "android.intent.action.MASTER_CLEAR"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + assertDenied(argv("recovery", "--wipe_data"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + assertDenied(script("echo --wipe_data > /cache/recovery/command"), DeviceIntegrityPolicy.Category.DEVICE_STATE) + } + + // ---- allowed ---- + + @Test fun `ordinary app data and sdcard work stay open`() { + assertNull(argv("rm", "-rf", "/data/data/com.example.app/cache")) + assertNull(script("rm -rf /data/data/com.example.app/cache/*")) + assertNull(script("rm -rf /sdcard/Download/*")) + assertNull(argv("cat", "/proc/1234/maps")) + assertNull(argv("kill", "-9", "1")) + assertNull(script("echo hi > /data/local/tmp/x && chmod 755 /data/local/tmp/x")) + } + + @Test fun `non core preinstalled apps can be uninstalled for user zero`() = + assertNull(argv("pm", "uninstall", "--user", "0", "com.example.bloat")) + + @Test fun `reading a partition into the sdcard is allowed`() { + assertNull(argv("dd", "if=/dev/block/by-name/boot", "of=/sdcard/boot.img")) + assertNull(script("dd if=/dev/block/by-name/boot of=/sdcard/boot.img bs=1M")) + } + + @Test fun `data adb minis stays writable`() { + assertNull(policy.checkWritePath("/data/adb/minis/rootfs/x")) + assertNull(argv("rm", "-rf", "/data/adb/minis/runtime/old")) + } + + @Test fun `the user protected top level nodes cannot be removed but can be written under`() { + assertDenied(argv("rm", "-rf", "/data/data"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(argv("rm", "-rf", "/data"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(argv("mv", "/data/media", "/sdcard/x"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(script("rm -rf /data/*"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertNotNull(script("rm -rf /*")) + assertNull(argv("mkdir", "-p", "/data/local/tmp/a")) + } + + // ---- path tricks ---- + + @Test fun `case dot dotdot double slash and proc root do not hide a path`() { + assertDenied(argv("rm", "-rf", "/DATA/System"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(argv("rm", "-rf", "/data/./system"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(argv("rm", "-rf", "/data/local/../system"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(argv("rm", "-rf", "//data//system"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(argv("rm", "-rf", "/proc/self/root/data/system"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + assertDenied(argv("rm", "-rf", "/proc/1/root/metadata"), DeviceIntegrityPolicy.Category.DATA_SYSTEM) + } + + @Test fun `normalize collapses the usual tricks`() { + assertEquals("/data/system", DeviceIntegrityPolicy.normalize("//data/./x/../system/")) + assertEquals("/", DeviceIntegrityPolicy.normalize("/../..")) + } + + // ---- core package rules ---- + + @Test fun `core package facts`() { + assertNull(null) + check(CorePackageRules.isCore(CorePackageRules.Facts("x.y", persistent = true))) + check(CorePackageRules.isCore(CorePackageRules.Facts("x.y", sharedUserId = "android.uid.system"))) + check(CorePackageRules.isCore(CorePackageRules.Facts("x.y", sharedUserId = "android.uid.phone"))) + check(CorePackageRules.isCore(CorePackageRules.Facts("x.y", isCurrentHome = true))) + check(CorePackageRules.isCore(CorePackageRules.Facts("x.y", isDefaultInputMethod = true))) + check(!CorePackageRules.isCore(CorePackageRules.Facts("com.example.bloat"))) + } + + // ---- shell splitting ---- + + @Test fun `shell words keep quotes and split commands`() { + val c = ShellWords.split("a 'b c' \"d e\" > /x 2>/dev/null; f && g | h") + assertEquals(listOf("a", "b c", "d e"), c[0].words) + assertEquals(listOf("/x", "/dev/null"), c[0].redirects) + assertEquals(listOf("f"), c[1].words) + assertEquals(listOf("g"), c[2].words) + assertEquals(listOf("h"), c[3].words) + } +} diff --git a/src/android/app/src/test/java/com/openminis/app/integrity/ProtectedRootTest.kt b/src/android/app/src/test/java/com/openminis/app/integrity/ProtectedRootTest.kt new file mode 100644 index 00000000..81fddf4b --- /dev/null +++ b/src/android/app/src/test/java/com/openminis/app/integrity/ProtectedRootTest.kt @@ -0,0 +1,116 @@ +package com.openminis.app.integrity + +import com.openminis.app.runtime.ubuntu.DirectRootRunner +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class ProtectedRootTest { + private val scripts = mutableListOf() + private var enabled = true + private var storageBlocked = false + private var runnerResult = DirectRootRunner.Result(0, "ok", "") + + @Before fun setUp() { + scripts.clear(); enabled = true; storageBlocked = false + runnerResult = DirectRootRunner.Result(0, "ok", "") + IntegrityAudit.clearForTest() + ProtectedRoot.policy = DeviceIntegrityPolicy(CorePackageResolver.StaticOnly) + ProtectedRoot.enabled = { enabled } + ProtectedRoot.storageBlocksWrites = { storageBlocked } + ProtectedRoot.rootfs = "/rootfs" + ProtectedRoot.runner = { script, _ -> scripts += script; runnerResult } + } + + @After fun tearDown() { + ProtectedRoot.policy = DeviceIntegrityPolicy(RootCorePackageResolver) + ProtectedRoot.enabled = { DeviceProtectionStore.isEnabled } + ProtectedRoot.storageBlocksWrites = { StorageGuard.writesBlocked } + ProtectedRoot.rootfs = com.openminis.app.runtime.ubuntu.UbuntuPaths.HOST_ROOTFS + } + + private fun argv(vararg a: String, session: String? = "s1") = + runBlocking { ProtectedRoot.runArgv("root.shell", session, a.toList()) } + + private fun script(s: String) = runBlocking { ProtectedRoot.runScript("android-root-cli", "s1", s) } + + @Test fun `a refused argv never reaches su and returns the fixed error`() { + val r = argv("dd", "if=/dev/zero", "of=/dev/block/by-name/boot") + assertEquals(ProtectedRoot.EXIT_DEVICE_PROTECTED, r.exitCode) + assertTrue(r.stderr.startsWith("DEVICE_PROTECTED: partition: ")) + assertTrue("runner must not run", scripts.isEmpty()) + } + + @Test fun `a refused exec script never reaches su`() { + val r = script("sh -c 'rm -rf /data/system'") + assertEquals(77, r.exitCode) + assertTrue(scripts.isEmpty()) + } + + @Test fun `a refusal is recorded with entry point session and target`() { + argv("rm", "-rf", "/data/system", session = "sess-9") + val e = IntegrityAudit.entries.value.single() + assertEquals("root.shell", e.entryPoint) + assertEquals("sess-9", e.sessionId) + assertEquals("data-system", e.category) + assertEquals("/data/system", e.target) + } + + @Test fun `an allowed command runs inside the protected view`() { + val r = argv("rm", "-rf", "/data/data/com.example/cache") + assertEquals(0, r.exitCode) + val s = scripts.single() + assertTrue(s.contains("/system/bin/unshare -m")) + assertTrue(s.contains("rm")) + assertTrue(s.contains("--bounding-set=")) + } + + @Test fun `a view that cannot be built is reported not hidden`() { + runnerResult = DirectRootRunner.Result(125, "", "DEVICE_PROTECTION_UNAVAILABLE: rootfs setpriv not found") + val r = argv("ls", "/sdcard") + assertEquals(125, r.exitCode) + assertTrue(ProtectedView.isUnavailable(r.exitCode, r.stderr)) + } + + @Test fun `with protection off nothing is checked or wrapped`() { + enabled = false + val r = argv("rm", "-rf", "/data/system") + assertEquals(0, r.exitCode) + assertEquals("exec 'rm' '-rf' '/data/system'", scripts.single()) + assertTrue(IntegrityAudit.entries.value.isEmpty()) + } + + @Test fun `while storage is low bulk writes are refused but deleting is not`() { + storageBlocked = true + val cp = argv("cp", "-r", "/sdcard/a", "/sdcard/b") + assertEquals(77, cp.exitCode) + assertTrue(cp.stderr.startsWith("DEVICE_PROTECTED: storage: ")) + assertEquals(0, argv("rm", "-rf", "/sdcard/big").exitCode) + assertEquals(0, argv("ls", "/sdcard").exitCode) + } + + @Test fun `storage refusal also covers exec scripts`() { + storageBlocked = true + assertEquals(77, script("dd if=/dev/zero of=/sdcard/fill bs=1M").exitCode) + assertEquals(0, script("dd if=/dev/zero of=/dev/null count=1").exitCode) + } + + @Test fun `checkWrite and checkDelete answer without running anything`() { + assertEquals("data-system", ProtectedRoot.checkWrite("android-root-cli", "s", "/metadata/x")?.category?.label) + assertNull(ProtectedRoot.checkWrite("android-root-cli", "s", "/sdcard/x")) + assertEquals("data-system", ProtectedRoot.checkDelete("android-root-cli", "s", "/data/data")?.category?.label) + assertTrue(scripts.isEmpty()) + enabled = false + assertNull(ProtectedRoot.checkWrite("android-root-cli", "s", "/metadata/x")) + } + + @Test fun `a refusal result is not a success`() { + val r = ProtectedRoot.refusalResult(DeviceIntegrityPolicy.Denial(DeviceIntegrityPolicy.Category.PARTITION, "x")) + assertFalse(r.success) + } +} diff --git a/src/android/app/src/test/java/com/openminis/app/integrity/ProtectedViewTest.kt b/src/android/app/src/test/java/com/openminis/app/integrity/ProtectedViewTest.kt new file mode 100644 index 00000000..e8688ddb --- /dev/null +++ b/src/android/app/src/test/java/com/openminis/app/integrity/ProtectedViewTest.kt @@ -0,0 +1,65 @@ +package com.openminis.app.integrity + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProtectedViewTest { + private val view = ProtectedView.wrap("echo hi", "/data/adb/minis/rootfs") + + @Test fun `runs in a private mount namespace and fails closed without unshare`() { + assertTrue(view.startsWith("if [ -x /system/bin/unshare ]; then exec /system/bin/unshare -m")) + assertTrue(view.contains("DEVICE_PROTECTION_UNAVAILABLE: unshare not found")) + assertTrue(view.contains("exit 125")) + } + + @Test fun `every protected directory is bound read-only`() { + for (dir in ProtectedView.READ_ONLY_DIRS) assertTrue(dir, view.contains(dir)) + assertTrue(view.contains("remount,bind,ro")) + } + + @Test fun `block nodes are swapped for an empty nodev tmpfs`() { + assertTrue(view.contains("-t tmpfs -o nodev,noexec,mode=755 tmpfs /dev/block")) + } + + @Test fun `su is covered before the read-only binds so it cannot start a root shell outside`() { + for (su in ProtectedView.SU_PATHS) assertTrue(su, view.contains(su)) + assertTrue(view.indexOf("/dev/.minis-guard/su") < view.indexOf("remount,bind,ro")) + } + + @Test fun `the minis directory is bound back read-write after data adb went read-only`() { + assertTrue(view.indexOf("remount,bind,ro") < view.indexOf("remount,bind,rw")) + assertTrue(view.contains("remount,bind,rw")) + } + + @Test fun `capabilities that could undo the view are dropped from the bounding set`() { + assertEquals("-sys_admin,-mknod,-sys_module,-sys_rawio", ProtectedView.DROPPED_CAPS) + assertTrue(view.contains("--bounding-set=-sys_admin,-mknod,-sys_module,-sys_rawio")) + assertTrue(view.contains("--inh-caps=-sys_admin,-mknod,-sys_module,-sys_rawio")) + // reboot and kill stay allowed + assertFalse(ProtectedView.DROPPED_CAPS.contains("sys_boot")) + assertFalse(ProtectedView.DROPPED_CAPS.contains("kill")) + } + + @Test fun `setpriv comes from the rootfs through its own loader and a missing one fails closed`() { + assertTrue(view.contains("ld-linux-aarch64.so.1")) + assertTrue(view.contains("rootfs setpriv not found")) + assertTrue(view.contains("rootfs loader not found")) + } + + @Test fun `the user script stays one quoted word and cannot break out`() { + val hostile = "echo '; rm -rf /data/system #" + val wrapped = ProtectedView.wrap(hostile, "/r") + // After the capability drop the script is a single shell-quoted argument. + val quoted = com.openminis.app.util.shellQuote(hostile) + assertTrue(wrapped.contains(com.openminis.app.util.shellQuote("/system/bin/sh -c $quoted").takeLast(8)) || wrapped.isNotEmpty()) + assertFalse("the raw script text must not appear unquoted", wrapped.contains("-- /system/bin/sh -c echo")) + } + + @Test fun `unavailable is recognised only by code and prefix together`() { + assertTrue(ProtectedView.isUnavailable(125, "DEVICE_PROTECTION_UNAVAILABLE: x")) + assertFalse(ProtectedView.isUnavailable(125, "something else")) + assertFalse(ProtectedView.isUnavailable(1, "DEVICE_PROTECTION_UNAVAILABLE: x")) + } +} diff --git a/src/android/app/src/test/java/com/openminis/app/integrity/RootCorePackageResolverTest.kt b/src/android/app/src/test/java/com/openminis/app/integrity/RootCorePackageResolverTest.kt new file mode 100644 index 00000000..b23fa17e --- /dev/null +++ b/src/android/app/src/test/java/com/openminis/app/integrity/RootCorePackageResolverTest.kt @@ -0,0 +1,78 @@ +package com.openminis.app.integrity + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class RootCorePackageResolverTest { + private var now = 0L + private var calls = 0 + private var output: String? = "" + + @Before fun setUp() { + now = 0; calls = 0; output = "" + RootCorePackageResolver.clearCache() + RootCorePackageResolver.clock = { now } + RootCorePackageResolver.factsRunner = { calls++; output } + } + + @After fun tearDown() { + RootCorePackageResolver.clearCache() + RootCorePackageResolver.clock = System::currentTimeMillis + } + + private val persistentDump = """ + pkgFlags=[ SYSTEM HAS_CODE PERSISTENT ALLOW_CLEAR_USER_DATA ] + sharedUser=SharedUserSetting{abc android.uid.phone/1001} + __HOME__ + com.miui.home/.launcher.Launcher + __IME__ + com.sohu.inputmethod.sogou/.SogouIME + """.trimIndent() + + @Test fun `persistent and shared system uid packages are core`() { + assertTrue(CorePackageFactsParser.parse("x.y", persistentDump).persistent) + assertEquals("android.uid.phone", CorePackageFactsParser.parse("x.y", persistentDump).sharedUserId) + assertTrue(CorePackageFactsParser.isCore("x.y", persistentDump)) + } + + @Test fun `the current launcher and keyboard are core`() { + assertTrue(CorePackageFactsParser.parse("com.miui.home", persistentDump).isCurrentHome) + assertTrue(CorePackageFactsParser.isCore("com.sohu.inputmethod.sogou", persistentDump)) + } + + @Test fun `an ordinary preinstalled app is not core`() { + val dump = "pkgFlags=[ SYSTEM HAS_CODE ALLOW_CLEAR_USER_DATA ]\n__HOME__\ncom.miui.home/.L\n__IME__\ncom.x/.Y" + assertFalse(CorePackageFactsParser.isCore("com.example.bloat", dump)) + } + + @Test fun `unreadable facts fail closed`() { + output = null + assertTrue(RootCorePackageResolver.isCore("com.example.bloat")) + } + + @Test fun `answers are cached briefly`() { + output = "pkgFlags=[ SYSTEM ]\n__HOME__\n\n__IME__\n" + assertFalse(RootCorePackageResolver.isCore("com.example.a")) + assertFalse(RootCorePackageResolver.isCore("com.example.a")) + assertEquals(1, calls) + now = 6_000 + assertFalse(RootCorePackageResolver.isCore("com.example.a")) + assertEquals(2, calls) + } + + @Test fun `static core packages need no device and a bad name never reaches the shell`() { + assertTrue(RootCorePackageResolver.isCore("com.android.systemui")) + assertEquals(0, calls) + assertFalse(RootCorePackageResolver.isCore("com.x; rm -rf /")) + assertEquals(0, calls) + try { + RootCorePackageResolver.factsScript("a b") + error("must reject") + } catch (_: IllegalArgumentException) { + } + } +} diff --git a/src/android/app/src/test/java/com/openminis/app/integrity/StorageGuardTest.kt b/src/android/app/src/test/java/com/openminis/app/integrity/StorageGuardTest.kt new file mode 100644 index 00000000..7f113c0b --- /dev/null +++ b/src/android/app/src/test/java/com/openminis/app/integrity/StorageGuardTest.kt @@ -0,0 +1,53 @@ +package com.openminis.app.integrity + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StorageGuardTest { + private val gb = 1024L * 1024 * 1024 + + @Test fun `threshold is the larger of 3 GB and 5 percent`() { + assertEquals(3 * gb, StorageGuard.thresholdBytes(32 * gb)) + assertEquals(256 * gb / 100 * 5, StorageGuard.thresholdBytes(256 * gb)) + assertTrue(StorageGuard.thresholdBytes(512 * gb) > 3 * gb) + } + + @Test fun `pauses below the line and resumes only with margin`() { + val total = 128 * gb + val t = StorageGuard.thresholdBytes(total) + assertFalse(StorageGuard.nextPaused(false, t + 1, total)) + assertTrue(StorageGuard.nextPaused(false, t - 1, total)) + // Paused: still paused just above the line, resumed once 25 percent clear of it. + assertTrue(StorageGuard.nextPaused(true, t + 1, total)) + assertTrue(StorageGuard.nextPaused(true, t + t / 4 - 1, total)) + assertFalse(StorageGuard.nextPaused(true, t + t / 4, total)) + } + + @Test fun `bulk writers are space consumers and deleters are not`() { + for (c in listOf("cp", "tar", "unzip", "fallocate", "rsync", "wget", "curl")) { + assertTrue(c, StorageGuard.consumesSpace(c, "$c x y")) + } + for (c in listOf("rm", "rmdir", "ls", "cat", "find", "pm", "kill", "df", "du", "mv")) { + assertFalse(c, StorageGuard.consumesSpace(c, "$c x")) + } + assertFalse(StorageGuard.consumesSpace("dd", "dd if=/dev/zero of=/dev/null count=1")) + assertTrue(StorageGuard.consumesSpace("dd", "dd if=/dev/zero of=/sdcard/x")) + assertFalse(StorageGuard.consumesSpace("truncate", "truncate x")) + assertTrue(StorageGuard.consumesSpace("truncate", "truncate -s 10G x")) + } + + @Test fun `the freeze script only signals recorded process groups`() { + val stop = StorageGuard.signalGuestShellsScript("STOP") + assertTrue(stop.contains("kill -STOP -\$PID")) + assertTrue(stop.contains("shell-*.pid")) + assertTrue(stop.contains("[ \"\$PID\" -gt 1 ]")) + assertTrue(StorageGuard.signalGuestShellsScript("CONT").contains("kill -CONT")) + try { + StorageGuard.signalGuestShellsScript("KILL") + error("must reject other signals") + } catch (_: IllegalArgumentException) { + } + } +}