Skip to content

feat(integrity): 设备完整性保护 — Agent 的 Root 不能把手机弄坏 (#182) - #186

Merged
Slacker-LLC merged 6 commits into
mainfrom
feat/device-integrity-protection
Oct 10, 2026
Merged

Slacker-LLC merged 6 commits into
mainfrom
feat/device-integrity-protection

Conversation

@Slacker-LLC

Copy link
Copy Markdown
Owner

Closes #182。叠在 #185 之上(base 为 feat/root-only-drop-shizuku),#185 合并后改 base 到 main。

做了什么

目标只有一个:本地 Agent 通过 root.shell、android-root-cli、Android 系统工具拿到的 Root,不能把手机弄到无法开机或无法使用。不弹窗、不让用户逐条确认,命中就直接拒绝,错误固定为:

DEVICE_PROTECTED: <category>: <target>      # exit code 77
DEVICE_PROTECTION_UNAVAILABLE               # exit code 125,保护视图建不起来时失败关闭

两层:

  1. 策略(DeviceIntegrityPolicy,纯 Kotlin):对 argv / 脚本做检查,给出清晰的拒绝原因,主机上可测。
    • 保护区:/dev/block、/dev/mem、/dev/kmem、/system、/system_ext、/vendor、/product、/odm、/apex、/data/system*、/data/misc*、/data/vendor*、/data/property、/data/apex、/data/unencrypted、/data/adb(minis 自己的目录除外)、/metadata、/cache/recovery。/、/data、/data/data、/data/user、/data/user_de、/data/app、/data/media 这些节点本身只拒绝破坏性操作。
    • 命令:展开 sh -c / su -c / env / busybox / timeout / nice 等包装;跟踪 cd;find -delete/-exec、mount remount、dd of=、cp/mv/ln、块设备工具、am 恢复出厂、pm/cmd package 的卸载 / 禁用 / 清数据 / 挂起 / 隐藏、remove-user 0、settings put device_provisioned / user_setup_complete。
    • 路径规范化://、.、..、/proc/<pid|self>/root、大小写;glob 展开按保守处理。
    • 核心包:静态清单(含 llc.slacker.minis)+ 设备事实(persistent、android.uid.* 共享 UID、当前桌面、默认输入法)。App 没有 QUERY_ALL_PACKAGES,事实由一条固定的只读 Root 脚本取得;取不到时按核心包处理(失败关闭)。
  2. 保护视图(ProtectedView):字符串匹配看不穿变量和 $(),所以真正的强制在这里。命令在独立 mount namespace 里运行:保护区只读 bind;/dev/block 换成空的 nodev tmpfs;su 被桩替换(不能再 su 一次绕过);bounding set 去掉 sys_admin / mknod / sys_module / sys_rawio。去 capability 用的是 rootfs 里的 setpriv,通过 ld-linux-aarch64.so.1 --library-path 在 chroot 之外执行(toybox 没有 setpriv)。

入口覆盖:PrivilegedCommandRunner(所有 Android 系统工具)、RootShellHandler(root.shell)、RootProcess(android-root-cli 全部子命令,含 exec;exec 走受保护的 raw 路径)。App 自己构造的基础设施动作(rootfs、namespace、bind、chroot、网络 helper、无障碍修复)仍直接走 DirectRootRunner,不经过策略。

存储水位(StorageGuard):/data 剩余空间低于 max(3 GB, 5%) 时,冻结 guest shell 进程组(SIGSTOP),拒绝新的批量写入命令(cp / dd / tar / unzip / fallocate / rsync / wget / curl 等),发一条通知;回到阈值 +25% 后 SIGCONT 恢复。只暂停写入,从不删除,rm 始终可用以便腾空间。

分区读取:android-root-cli partition read(App 构造 dd,在保护视图之外,目标路径过写入检查)。读取分区是允许的,写入不允许。

开关:设置 → 权限里的「设备保护」,默认开,只有用户能改。和 AutonomyStore 一样:没有配置字段、不在备份白名单里、Agent 没有任何工具能碰到它。拒绝记录在设置里的诊断列表(最近 200 条)和 App 日志。

已知残余(写入 06-CURRENT-GAPS.md)

  • 裸 service call 等经 Binder 的操作不在策略里,只有保护视图(capability + 只读挂载)间接限制。
  • 变量、$()、eval 拼出来的命令,策略层看不见,依赖保护视图兜底。
  • 开关关掉后,上面所有保护都不生效,这是用户的明确选择。

验证

  • :app:testDebugUnitTest:3531 项,0 失败(LANG=C.UTF-8)。新增 58 项 integrity 测试:DeviceIntegrityPolicyTest、ProtectedRootTest、ProtectedViewTest、StorageGuardTest、RootCorePackageResolverTest。
  • 否定用例(按 AGENTS.md 硬规则 9):每个保护区各有拒绝用例;包装命令嵌套拒绝;路径穿越和 /proc/self/root 拒绝;核心包取不到事实时拒绝;保护视图建不起来时 exit 125 而不是退化成不保护运行;开关关闭时才放行;存储水位下批量写拒绝但 rm 放行。
  • :app:lintDebug:0 error。发现并修复一处 MissingPermission(StorageGuard 发通知前补上 POST_NOTIFICATIONS 检查)。
  • :app:minifyReleaseWithR8:通过。

未验证(需要真机)

以下全部只在主机单元测试里验证了逻辑,没有在任何真实设备上跑过:

  • 保护视图在 HyperOS / KernelSU / Magisk 上能否建立:unshare -m、只读 bind、/dev/block 覆盖、rootfs setpriv 经 glibc loader 的调用。
  • bounding set 去掉 sys_admin 之后,android-root-cli 里依赖这些能力的合法子命令是否受影响(目前判断不会,但没有实测)。
  • dumpsys package / cmd package resolve-activity 在不同 ROM 上输出格式是否与解析器一致;不一致时会按核心包处理,表现为「过度拒绝」而不是「漏拒绝」。
  • 存储水位:SIGSTOP/SIGCONT 对真实 guest shell 的效果,以及 /data 与 StatFs 看到的卷是否一致。
  • OEM 对该通知渠道的处理。

🤖 Generated with Claude Code

https://claude.ai/code/session_01PV4ECd3Yzu9k81sw7vHjS3


Generated by Claude Code

Slacker-LLC and others added 5 commits October 10, 2026 07:05
…reen on a libsu root service

Root is the app's one privileged path. The code before this change is
archived on the branch archive/before-root-only (6a6b450); the rootless
PRoot + Ubuntu backend is shelved as #184.

- android-shizuku-cli becomes android-root-cli: the same subcommands and
  JSON, run as root through RootProcess -> DirectRootRunner (su). The
  shizuku_cli permission key migrates to root_cli; a stale
  android-shizuku-cli wrapper in an existing rootfs is removed on the next
  guest CLI install.
- The virtual screen service is hosted by a libsu RootService
  (VirtualScreenRootService, daemon mode) instead of a Shizuku UserService,
  and accepts uid 0 only. The Shizuku-reserved destroy() leaves the AIDL;
  libsu ends the daemon on app update, so no service version is needed.
  Probes saved under the Shizuku host no longer count (host marker in the
  fingerprint): the self-check must pass again before it is re-enabled.
- Accessibility repair, clearing restricted settings and one-tap authorize
  use Root. Settings > System gets a Root page in place of "Root & Shizuku";
  the readiness checklist drops Shizuku.
- Removed: ShizukuManager/ShizukuBackend, the Shizuku settings page, the
  Shizuku API/provider dependency, manifest provider/permission/queries,
  and every Shizuku string in all eight locales (new strings translated).
- Contracts: AGENTS.md rule 3 and 04 list android-root-cli (with its raw
  `exec` root shell) and the virtual-screen root service; 05 describes the
  new VScreen host; 06 records what is unverified on a device and two gaps
  (MCP can reach android-root-cli through linux.shell; #182 not built).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PV4ECd3Yzu9k81sw7vHjS3
…design

The remote MCP path stays gated by the one-shot MCP_CONFIRM approval, which
AutonomyMode (全自动) does not bypass, and by the default-off root_cli switch.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PV4ECd3Yzu9k81sw7vHjS3
Refuses only what can leave the phone unbootable or unusable: block-device and
system-partition writes, /data system state, core packages, factory reset and
user 0 removal. Path tricks (case, ./.., //, /proc/*/root) and sh -c / su -c /
env / find -exec wrappers are followed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PV4ECd3Yzu9k81sw7vHjS3
Agent- and guest-CLI-originated Root commands now pass ProtectedRoot: a pure
DeviceIntegrityPolicy refuses what could leave the phone unbootable or unusable
(partition and system-partition writes, /data system state, core packages,
factory reset, user 0), with a fixed DEVICE_PROTECTED error and no prompt; the
command then runs in a ProtectedView (read-only binds, no block nodes, su stub,
CAP_SYS_ADMIN/MKNOD/SYS_MODULE/SYS_RAWIO dropped). Settings switch (default on,
user-only), audit list, storage watermark that freezes guest shells and refuses
bulk writes, and a read-only partition export. Device behaviour is unverified.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PV4ECd3Yzu9k81sw7vHjS3
main carries #185 as rebased commits (d57ee95, 284b87a) whose tree is byte-identical
to the #185 commits this branch was built on (b8b8727); only the commit ids differ,
which is what made git report conflicts. Tree of this branch is unchanged by the merge.
@Slacker-LLC
Slacker-LLC merged commit 720674b into main Oct 10, 2026
26 of 28 checks passed
@Slacker-LLC
Slacker-LLC deleted the feat/device-integrity-protection branch October 10, 2026 17:06

Copy link
Copy Markdown
Owner Author

Android debug lint 在 e6c1c83 上第一次失败,原因是 CI 机器安装 NDK 28.2.13676358 时压缩包损坏("Error on ZipFile unknown archive"),与本 PR 无关(本 PR 的树与上一个通过的头 09f29a2 完全相同)。已重跑一次,全部检查通过。


Generated by Claude Code

Slacker-LLC pushed a commit that referenced this pull request Oct 10, 2026
#186, #188 and #189 landed on main. The merge was clean (CHANGELOG, 06-CURRENT-GAPS
and the string files only had adjacent additions); CI on this head tests the combination.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PV4ECd3Yzu9k81sw7vHjS3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

设备完整性保护:特权通道只拦会让手机用不了的操作,其余全部放开

2 participants