Skip to content

fix(security): Critical 批次加固——发布链、数据完整性、信任边界与 Dev Suite 授权 - #25

Merged
tall-1997 merged 4 commits into
mainfrom
fix/critical-hardening
Oct 14, 2026
Merged

tall-1997 merged 4 commits into
mainfrom
fix/critical-hardening

Conversation

@tall-1997

Copy link
Copy Markdown
Owner

背景

对 18dff0c 做了一轮全仓深度审查(bug / 未优化 / 冗余 / 悬空引用 / 孤儿代码)后,把其中的 Critical 级问题按本次 PR 逐条修掉。审查报告里剩下的 High/Medium 项没有包含在这里,方便按严重度分批评审。

审查中报出的「MCP OAuth 令牌明文写入 minis-global/ 且被备份规则包含」经确认为项目既有设计,本 PR 不动,后续审查也不再作为异常上报。

逐条对应(4 个提交)

ed77345 fix(release)

  • 签名口令不再进 git。release.keystore 仍是仓库内公开的默认上传证书(BUILDING.md 记录的既有设计,便于 CI/本地共用同一张证书,应用内更新按证书指纹比对)。问题在于 signing.properties 里的明文口令:私钥已公开时,口令再公开等于任何人都能签出与官方同身份、可覆盖已安装版本的 APK。现在只留 signing.properties.example,本地填口令,CI 走 secrets;gradle 在「有 keystore、无口令」时给出明确提示而不是让签名任务抛一句关于损坏文件的错。
  • CI 不再有 debug 签名兜底。原逻辑在没有任何签名材料时打印一句「APK will be debug-signed」然后继续发布——debug 身份替换不了正式安装,等于把已发布用户钉死在断掉的升级路径上。现在直接失败。
  • tag 发布从 5 个提交前起就是坏的:66841fd 删了 docs/,而 Select versioned release notes 仍 awk … docs/RELEASE-NOTES.zh.md,两步之后 test -s "$NOTES"(set -euo pipefail)让每个 v*/*-linux 标签发布失败。现改为从提交历史生成 release body(checkout 已是 fetch-depth: 0),仍保留「release-notes/<tag>.md 手写优先」。
  • 清掉其余 24 处指向已删除 docs/ 的死链(README / BUILDING / LINUX / gradle.properties / settings.gradle.kts / app/build.gradle.kts / minis-mirror / MainActivity / debug/*DebugMethods / ReadImageTool / AccessibilityOffloadHandler / 测试里的 release-body 样例)。
  • PR feat(dev-suite): 声明式工具链套件 + Android SDK 按需下载(-36MB APK,PR-A~D 一次落地) #23 的「−36MB APK」此前并未生效:RootfsManager 的 SDK 解包代码已删、CI 也只再拷 ubuntu-base.tar.gz,但两个 zip 仍在 src/main/assets 里被 AGP 打进 APK(21.2MB + 14.8MB),成了没有任何读取者的死载荷。现在真的移出仓库;VendoredAssetIntegrityTest 与 build_apk_aarch64.sh:180 早有 absent 容错分支。BUILDING.md 中「跑 repair_vendor_zip 修 zip / 搜索 Bundled aapt2 not found」的指引同步为按需下载流程(那个报错串本身已被删掉),slim_android_cmdline_tools.py 标注已无调用方。
  • UpdateDownloadManager 的 sha256 闸门从「API 给了才校验」改为「没给就不装」:镜像源解析的是同一个 asset URL,没有独立背书,而未公布哈希的下载此前只靠那张公开证书把关。手动路径(发布页)仍然可用。

1acdf21 fix(data,backup)

  • MCPRepository / EnvVarRepository 不再把「读不出来」当成「一个都没有」,从而在下次 CRUD 时用内存里的空列表覆写 servers.json / env-vars.json。这正是 ProviderRepositoryLoad 用 [T-android-provider-empty-load-wipe] 专门防过的失败模式(注释里记着「18 个实例变 5 个」),只是没同步到这两处;而 servers.json 恰是用户可在 guest CLI / 文件浏览器里手改的输入。现在脏读期间拒绝落盘,save() 返回布尔、add/update 如实上报失败;EnvVarRepository 同时改用仓库里已有的 writeTextAtomic。
  • 备份包 manifest MAC 改为 fail-closed。sidecar 缺失即跳过校验、只有内嵌字段时记条 info 也跳过、两者皆无时连日志都没有直接放行;而 BackupExporter 在 Android 上只写 sidecar。删掉 manifest.mac 再改明文 manifest(抹掉 providers 条目以隐藏凭据存在、去掉 integrity 成员使损坏成员不再被校验)即可通过还原——正是该函数 KDoc 声称防住的攻击。现在加密包在两种 MAC 全缺时拒绝还原;iOS 的仅内嵌形态保留为兼容路径。
  • kdf.iterations 本次不加下限,并说明为什么:iterations 是随 manifest 携带的格式参数(跨平台互测向量就钉在 1000 轮),而且调低它并不帮助离线破解——verifier 是按真实轮次算的,改小反而解不开。真正的防线是上面那条强制的 manifest MAC。已把这段推理写成注释,免得后人再加一遍同样的守卫。
  • 测试:BackupPackageReaderTest 新增「无 MAC 的加密包必须拒绝解锁」,并给原正向解锁用例补上 sidecar(真实包总是带)。

3b07fae fix(security)

  • WebView 本地文件不再对任意网页可读:拦截 minis:// 时一律附 Access-Control-Allow-Origin: *,agent 打开的任意页面都能 fetch("minis://workspace/…") 读走字节(工作区里有下载的用户文件与工具暂存输出)。现在按请求方 Origin 决定:http/https 来源不发 CORS 头(顶层导航不受影响,跨源读取被挡),minis:// 工作区页面仍拿回自身 Origin,无 Origin 的顶层导航本就不需要该头。
  • allow 规则不能再洗白硬拒绝:步骤 [1] 在所有拒绝层之前返回 Allow,匹配对象是整条拼接命令,规则 cat 即放行 cat x; rm -rf /data/data/…;patternMatches 结尾的 target.contains(pattern.trim('*')) 让 cat 连 grep category 都命中;空 pattern 等价于全放行。这与同文件 PrefixRulePolicy 声明的顺序契约冲突。现在 allow 按 shell 分段逐一匹配(sudo * / timeout * 这类用户自己写的包装前缀语义保留),含 FATAL_BANNED 段时一律不生效;deny 维持宽松匹配(多拦不是安全问题,收紧它会违背用户写规则的意图)。附 6 条回归测试,含正向用例证明这不是一刀切废掉 allow。
  • 加密 prefs 不再把单点损坏放大成全量凭据丢失,也不再静默降级为明文:所有 store 共用 MasterKey.DEFAULT_MASTER_KEY_ALIAS 与同一份共享 Tink keyset,原逻辑两者一起删,于是一个 store 抛 AEADBadTag 就让其余全部不可解,且每个受害者下次冷启还会重复同样的删除;回退路径是 _plain_fallback 明文 prefs,此后录入的 API key / SSH 口令长期明文保存,而日志写的是「credentials lost」。现在只删该文件自己的 XML 并重试,仍失败则返回不落盘的内存 store。别名保持不变——既有安装的 keyset 都包在这把钥匙下,换别名会直接让存量凭据失效。
  • ui_action / ui_read 参数校验与真实状态:坐标经 v.toString() 裸插进 bash -c(x = "0 0; <cmd>" 即成为 shell,而 SecurityGate 审的是 ui_action、看不到展开后的命令)、direction 未校验、window_token 未做 ' 转义;同时脚本尾的 || true 把退出码钉死为 0,ui_read 更是硬编码 success = true,于是失败的操作全部上报成功,GUI agent 的「读屏验证」在空转,onToolFailure 也永远收不到信号。现在坐标只接受整数、direction 走 up|down|left|right 白名单(与 AccessibilityOffloadHandler 判定一致)、统一 shell-escape,并按真实退出码给出 EXECUTION_FAILED / UNSUPPORTED / PERMISSION_DENIED。

e580521 fix(dev-suite)

  • 深度链接不再自动装全家桶:minis://devsuite/install/<bundleId> 此前把 bundle 的全部组件(含界面上刻意做成可选项的 flutter / jadx / rust / NDK)直接交给 assembleBundleAsync,不弹确认、不看流量,而系统提示词还告诉模型「这个链接会自动开始装配」——一条可被提示注入诱导发出的链接即可静默拉起约 5–8GB 下载。现在链接只打开该 bundle 的确认对话框(复用 DevSuiteBundleDialog:必选预选中、可选不预选),装配仍需用户点一下。
  • 安装前先量磁盘:新增 DevComponent.estimatedBytes,assembleBundleAsync 在派下第一个字节之前用 StatFs 比对总量,不足即拒绝并把原因写进组件状态(沿用现有 detail 文案通道,不新增 17 语言的字符串)。此前唯一制动是运行期 <2GB 的 diskRefusal,也就是说大套件会先盲下、把卷写满再失败,留下半解包的 SDK 树。
  • recoverInterrupted 不再把在跑的安装标成失败:它无条件把所有 INSTALLING 改 FAILED("安装被中断"),而调用点是 DevSuiteSection 的 LaunchedEffect(Unit),且 busy 是逐组件置位的——安装途中离开设置页再回来即触发。现在只在没有安装运行时回收;组件间间隙里没有 INSTALLING 项,冷启动的崩溃恢复语义不变。

验证

  • ./gradlew :app:compileReleaseKotlin ✅(Android SDK 36 / NDK 29.0.14206865,本机 JDK 21)
  • ./gradlew :app:testReleaseUnitTest 2951 项:本 PR 涉及范围全部通过;新增/改动测试包括 SecurityGateImplTest 6 条、BackupPackageReaderTest 1 条(另修正 1 条原用例的前置条件)。
  • 仅剩 2 项失败:MinisMediaResolutionTest > fallback finds the own-session file by basename / fallback probes the global bind bucket。与本 PR 无关,属于既有的主机相关脆弱性:这两条断言 absolutePath.endsWith("minis-sessions/sess-a/attachments/chart.png"),而 Windows 上 File.getAbsolutePath() 用反斜杠(已用最小 Java 用例复现:endsWith(forward-slash form)=false,separator=\)。同文件里只断言 hit?.name 的兄弟用例全部通过。该文件本 PR 未改动,Linux CI 上正常。

需要 reviewer 知道的取舍

  1. 签名证书本身未轮换。换 key 会让所有已安装用户无法覆盖升级,这是发布决策、不是代码修复;本 PR 只做「口令不再公开 + 无 hash 不自动装 + CI 不再 debug 签名兜底」。如果这个 keystore 曾经签过任何对外分发的 APK,建议单独评估轮换。
  2. 备份 MAC 强制化对「比 sidecar 更早的 Android 包」会变严格。按当前代码与注释(Android 只写 sidecar,内嵌形态是 Swift 的重编码)推断不存在这种包,但浅克隆的历史无法完全证明。若你们确认有过「既无 sidecar 也无内嵌 MAC」的正式版,可以按 scheme 版本加一个带豁免期的分支。
  3. allow 规则的匹配收紧:以前一条宽规则能覆盖整条拼接命令,现在需要每个分段都被覆盖。这是安全修复的目的,但对已有用户配置是行为变化——如果你们更希望「显式 allow 仍然优先于硬拒绝」,请说一声,我改成仅对 FATAL_BANNED 保留优先拒绝。
  4. ui_read/ui_action 现在会如实返回失败。这两处仍使用宿主机 /bin/bash(真机上不存在,McpToolDispatcher.kt:685-690 记录过同一问题并已迁到 guest 路径)。那属于审查报告里 High/Medium 批次,本 PR 未动,因此这两个工具在真机上仍不可用——只是现在会明确报错而不是假装成功。
  5. 本 PR 只覆盖 Critical 批次。High/Medium(ToolOutputPolicy.workspaceRoot 从未赋值、minis-setup-* 未进 isSetup() 被 10 分钟墙腰斩、Anthropic/Gemini 严格 data: 与未脱敏错误体、FileReadTool 全量 readLines、AppNavigation 四则、ChatScreen 组合期双份全量 flatten、以及孤儿/冗余清理批次)仍在待办中。

tall-1997 added 4 commits October 15, 2026 00:16
三件在发布链上的事:

1. 签名口令不再进 git(release.keystore 仍是项目公开的默认上传证书,
   这是 BUILDING.md 记录的既有设计)。私钥已公开时,口令文件再公开就等于
   任何人都能签出与官方同身份、可覆盖安装的 APK ——
   `UpdateChecker.verifyApkSignature` 比的正是这张证书。
   本地:`cp signing.properties.example signing.properties` 填口令;
   CI:`Upload keystore` 步骤改为必须有 secrets,两者都没有时**直接失败**,
   不再退化成 debug 签名(那会让所有已安装用户失去升级路径)。
   同时 `UpdateDownloadManager.publishDownloaded` 的 sha256 闸门从
   「API 给了才校验」改为「没给就不装」:镜像源解析的是同一个 asset URL,
   没有独立背书,而未公布哈希的下载此前只靠那张公开证书把关。

2. `66841fd` 删了 docs/,但 `.github/workflows/android-apk.yml` 的
   `Select versioned release notes` 仍读 `docs/RELEASE-NOTES.zh.md`,
   于是打 tag 的正式发布从那时起必然在两步后的 `test -s "$NOTES"` 失败。
   现改为从提交历史生成 release body(checkout 已是 fetch-depth: 0),
   仍保留 `release-notes/<tag>.md` 手写优先。
   顺带清掉余下 24 处指向已删除 docs/ 的死链(README/BUILDING/LINUX/
   gradle.properties/settings.gradle.kts/app/build.gradle.kts/
   minis-mirror/MainActivity/debug/*DebugMethods/ReadImageTool/
   AccessibilityOffloadHandler 以及测试里的 release-body 样例)。

3. PR #23 声称 −36MB APK,但两个 zip 仍在 `src/main/assets` 里被打包:
   `RootfsManager` 的解包代码已删、CI 也只再拷 ubuntu-base.tar.gz,
   于是 21.2MB + 14.8MB 成了没有任何读取者的死载荷。现在真的移出仓库
   (`VendoredAssetIntegrityTest` 与 `build_apk_aarch64.sh` 早有 absent
   容错分支),BUILDING.md 里「修 zip / 找 Bundled aapt2 not found」的
   指引同步改为按需下载流程,`slim_android_cmdline_tools.py` 标注退休。

验证:`:app:compileReleaseKotlin` 通过;`:app:testReleaseUnitTest` 2951 项,
本提交涉及范围内无失败(仅剩 MinisMediaResolutionTest 两项在 Windows 主机
因断言写死正斜杠路径而失败,与本改动无关,Linux CI 上正常)。
两处「静默丢数据」和一处「形同不设防」:

1. `MCPRepository.readServersFile` 读/解析失败返回 emptyList(),而下一次
   add/update/toggle 的 `save()` 就把这份内存里的空列表写回 servers.json ——
   用户全部 MCP 服务器定义(含 oauth 配置)永久消失。同一仓库里
   `ProviderRepositoryLoad` 早为这一失败模式写了
   `[T-android-provider-empty-load-wipe]` 守卫并记录了「18 个实例变 5 个」的
   事故,只是没同步到 MCP;而 servers.json 恰恰是用户可在 guest CLI 与文件
   浏览器里手改的输入。现在记下 `diskUnreadable`,脏读期间拒绝落盘(`save()`
   返回 Boolean,add/update 如实上报失败),导入路径也说明「只在内存生效」。
   空文件与缺 `mcpServers` 键同样按脏读处理。
   `EnvVarRepository` 同形问题(非原子 `writeText` + 吞异常 → 元数据被空列表
   覆写,值孤留在加密 prefs 里)一并修:复用已有的 `writeTextAtomic` + 拒写守卫。

2. `BackupPackageReader.verifyManifestAuthenticity`:sidecar `manifest.mac`
   不存在就跳过校验,只有内嵌字段时记一条 info 也跳过,两者都没有时**连日志
   都没有**直接放行;而 `BackupExporter` 在 Android 上只写 sidecar。于是删掉
   sidecar 再改明文 manifest(抹掉 `providers` 条目以隐藏凭据存在、去掉
   `integrity` 成员使损坏成员不再被校验)即可通过还原。`unlock()` 又是先派生
   密钥、先过 verifier、后才验真,verifier 分不清「被改但自洽」的 manifest。
   现在加密包在两种 MAC 全缺时拒绝还原(保留 iOS 的仅内嵌形态作为兼容路径)。

   关于 kdf.iterations:本次不加下限。`iterations` 是随 manifest 携带的格式
   参数(跨平台互测向量就钉在 1000 轮),且调低它并不能帮助离线破解 —— verifier
   是按真实轮次算的,改小反而解不开。真正的防线是上面那条强制的 manifest MAC。
   已在 `deriveKeys` 处把这段推理写成注释,免得后人再加一遍。

测试:`BackupPackageReaderTest` 补一条「加密包无 MAC 必须拒绝还原」,并给原有
的正向解锁用例补上 sidecar(真实包总是带)。
1. WebView 本地文件不再对任意网页可读(`BrowserUseManager`)。
   拦截 minis:// 时一律附 `Access-Control-Allow-Origin: *`,意味着 agent
   打开的任何一个页面都能 `fetch("minis://workspace/…")` 把字节读走——
   会话工作区里有下载的用户文件与工具暂存输出。现在按请求方 Origin 决定:
   http/https 来源不发 CORS 头(顶层导航照旧可用,跨源读取被挡),
   同 `minis://` Scheme 的工作区页面仍拿回自身 Origin(自家资源照常加载),
   无 Origin 的顶层导航本就不需要它。

2. `SecurityGateImpl` 的 allow 规则不能再洗白硬拒绝。
   步骤 [1] 在所有拒绝层之前返回 Allow,而匹配是拿**整条拼接命令**做 glob,
   所以规则 `cat` 就放行 `cat x; rm -rf /data/data/…`;`patternMatches` 结尾
   还有 `target.contains(pattern.trim('*'))`,一条 `cat` 规则连
   `grep category` 都能命中;空 pattern 直接等价于全放行。这与同文件里
   `PrefixRulePolicy` 自己声明的顺序契约(「必须在所有硬拒绝之后」)矛盾。
   现在 allow 按 shell 分段逐一匹配(保留 `sudo *` / `timeout *` 这类用户
   自己写的包装前缀语义),含 FATAL_BANNED 段时 allow 一律不生效;deny 维持
   原有的宽松匹配——多拦不是安全问题,收紧它反而会违背用户写规则的意图。
   补 6 条回归测试(含正向用例,证明这不是把 allow 一刀切废掉)。

3. `EncryptedPrefsFactory` 停止把单点损坏放大成全量凭据丢失,也不再静默降级
   到明文。所有加密 prefs 共用 `MasterKey.DEFAULT_MASTER_KEY_ALIAS` **和同一份
   共享 Tink keyset**,原逻辑两者一起删:一个 store 抛 AEADBadTag 就让其余
   所有保险箱再也解不开,而且每个受害者下次冷启还会再执行一遍同样的删除。
   现在只删该文件自己的 prefs XML 并重试;仍失败则返回一个不落盘的内存 store
   —— 此前是回落到 `_plain_fallback` 明文文件,此后录入的 API key 与 SSH 口令
   全部明文长期保存,而日志写的是「凭据丢失」。

4. `ui_action` / `ui_read` 的参数校验与真实状态(此前已上报为 Critical)。
   模型传来的坐标经 `v.toString()` 裸插进 `bash -c`,`x = "0 0; <cmd>"` 即成
   为 shell —— SecurityGate 审的是 `ui_action`,看不到展开后的命令;
   `window_token` 用单引号包裹却没做 `'` 转义(`text`/`url`/`package_name`
   都做了),`direction` 完全未校验。同时脚本尾巴的 `|| true` 把 bash 退出码
   钉死为 0,`ui_read` 更是硬编码 `success = true`,于是点击失败、读屏失败
   全部上报成功,GUI agent 的「读屏验证」在空转,evolution 的
   `onToolFailure` 也永远收不到信号。
   现在:坐标只接受整数、direction 走 up|down|left|right 白名单(与
   AccessibilityOffloadHandler 的判定一致)、统一 shell-escape、去掉 `|| true`
   并按退出码给出 EXECUTION_FAILED / UNSUPPORTED / PERMISSION_DENIED。
1. `minis://devsuite/install/<bundleId>` 不再无条件装配整套。
   `PluginMarketScreen` 收到链接后把 bundle 的**全部**组件(含界面上刻意做成
   可勾选的 flutter / jadx / rust / NDK)直接交给 `assembleBundleAsync`,
   既不弹确认也不看流量;而 `ChatViewModelPromptExt` 给模型的系统提示写着
   「链接会打开市场并自动开始装配」——于是一条(可能被提示注入诱导模型发出的)
   聊天链接就能静默拉起约 5–8GB 下载。现在链接只负责打开该 bundle 的确认对话框
   (沿用 `DevSuiteBundleDialog`,必选预选中、可选不预选),装配仍需用户点一下。

2. 安装前按选中组件估算空间。新增 `DevComponent.estimatedBytes`,
   `assembleBundleAsync` 在派下第一个字节之前用 `StatFs` 比对总量,不足即拒绝
   并把原因写进组件状态(沿用现有 detail 文案通道,不新增 17 语言的字符串)。
   此前唯一的制动是运行期 `<2GB` 的 diskRefusal,也就是说大套件会先盲下、
   把卷写满再失败,并留下半解包的 SDK 树。

3. `recoverInterrupted` 不再把正在进行的安装标成失败。
   它无条件把所有 INSTALLING 改 FAILED("安装被中断"),调用点是
   `DevSuiteSection` 的 `LaunchedEffect(Unit)`,而 `busy` 是**逐组件**置位的:
   安装途中离开设置页再回来就把在跑的任务标成失败,卡片显示失败、
   readyCount/failed 一直抖到真实结果覆盖。现在只在没有安装运行时回收,
   组件之间的间隙里没有 INSTALLING 项,冷启动的崩溃恢复语义保持不变。
@tall-1997
tall-1997 merged commit b3a2136 into main Oct 14, 2026
1 check passed
tall-1997 pushed a commit that referenced this pull request Oct 14, 2026
#25 之后的复查里发现「按子串匹配 entryId」这条 bug 有两个同源实例,其中一个
上一批只修了一半:

- ScheduledTaskStore.dropEntryRefs 用的是 `binding.contains(id)`,与 ChatDao
  原来的 LIKE 完全同型:删除 `inst/gpt-4` 会连带取消所有绑定 `inst/gpt-4o`
  的定时任务,用户选的模型静默丢失。改为解析绑定 JSON 的 `entryId`
  (与 ScheduledAgentRunner 读的是同一形状)后做集合等值比较——是等值判定,不再是子串。
- ChatDao.clearBindingReferencing 上一批把裸 id 的 `%…%` 换成锚定
  `"entryId":"<id>"`,但谓词仍是 LIKE:模型 id 里合法的 `_` / `%` 会被当成
  通配符,`inst/model_x` 仍能命中 `inst/modelYx`,等于换个写法重现同一个
  over-match。改用 instr() 做字面子串定位,无通配符语义,NULL 绑定的行
  instr 返回 NULL 即自然不匹配。
- ProviderRepository 里那段「此查询是 LIKE,需要精确匹配变体」的协调注释已经
  过期(它描述的是修复前的状态),改写为记录两处现在的精确匹配契约,避免
  下次有人照着旧注释把谓词改回去。

无单元覆盖:ScheduledTaskStore 需要 Context.getSharedPreferences,本仓库
单测是纯 JVM(无 Robolectric),不为此引入测试依赖;Room 侧的 instr 谓词由
KSP 在编译期校验 SQL 合法性。
@tall-1997
tall-1997 deleted the fix/critical-hardening branch October 16, 2026 06:16
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.

1 participant