Repository navigation
fix(security): Critical 批次加固——发布链、数据完整性、信任边界与 Dev Suite 授权 - #25
Merged
Merged
Conversation
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
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 合法性。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景
对
18dff0c做了一轮全仓深度审查(bug / 未优化 / 冗余 / 悬空引用 / 孤儿代码)后,把其中的 Critical 级问题按本次 PR 逐条修掉。审查报告里剩下的 High/Medium 项没有包含在这里,方便按严重度分批评审。审查中报出的「MCP OAuth 令牌明文写入
minis-global/且被备份规则包含」经确认为项目既有设计,本 PR 不动,后续审查也不再作为异常上报。逐条对应(4 个提交)
ed77345fix(release)release.keystore仍是仓库内公开的默认上传证书(BUILDING.md 记录的既有设计,便于 CI/本地共用同一张证书,应用内更新按证书指纹比对)。问题在于signing.properties里的明文口令:私钥已公开时,口令再公开等于任何人都能签出与官方同身份、可覆盖已安装版本的 APK。现在只留signing.properties.example,本地填口令,CI 走 secrets;gradle在「有 keystore、无口令」时给出明确提示而不是让签名任务抛一句关于损坏文件的错。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手写优先」。docs/的死链(README / BUILDING / LINUX / gradle.properties / settings.gradle.kts / app/build.gradle.kts /minis-mirror/MainActivity/debug/*DebugMethods/ReadImageTool/AccessibilityOffloadHandler/ 测试里的 release-body 样例)。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,没有独立背书,而未公布哈希的下载此前只靠那张公开证书把关。手动路径(发布页)仍然可用。1acdf21fix(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。BackupExporter在 Android 上只写 sidecar。删掉manifest.mac再改明文 manifest(抹掉providers条目以隐藏凭据存在、去掉integrity成员使损坏成员不再被校验)即可通过还原——正是该函数 KDoc 声称防住的攻击。现在加密包在两种 MAC 全缺时拒绝还原;iOS 的仅内嵌形态保留为兼容路径。kdf.iterations本次不加下限,并说明为什么:iterations是随 manifest 携带的格式参数(跨平台互测向量就钉在 1000 轮),而且调低它并不帮助离线破解——verifier 是按真实轮次算的,改小反而解不开。真正的防线是上面那条强制的 manifest MAC。已把这段推理写成注释,免得后人再加一遍同样的守卫。BackupPackageReaderTest新增「无 MAC 的加密包必须拒绝解锁」,并给原正向解锁用例补上 sidecar(真实包总是带)。3b07faefix(security)minis://时一律附Access-Control-Allow-Origin: *,agent 打开的任意页面都能fetch("minis://workspace/…")读走字节(工作区里有下载的用户文件与工具暂存输出)。现在按请求方 Origin 决定:http/https 来源不发 CORS 头(顶层导航不受影响,跨源读取被挡),minis://工作区页面仍拿回自身 Origin,无 Origin 的顶层导航本就不需要该头。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。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。e580521fix(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:testReleaseUnitTest2951 项:本 PR 涉及范围全部通过;新增/改动测试包括SecurityGateImplTest6 条、BackupPackageReaderTest1 条(另修正 1 条原用例的前置条件)。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 知道的取舍
ui_read/ui_action现在会如实返回失败。这两处仍使用宿主机/bin/bash(真机上不存在,McpToolDispatcher.kt:685-690记录过同一问题并已迁到 guest 路径)。那属于审查报告里 High/Medium 批次,本 PR 未动,因此这两个工具在真机上仍不可用——只是现在会明确报错而不是假装成功。ToolOutputPolicy.workspaceRoot从未赋值、minis-setup-*未进isSetup()被 10 分钟墙腰斩、Anthropic/Gemini 严格data:与未脱敏错误体、FileReadTool全量readLines、AppNavigation 四则、ChatScreen组合期双份全量 flatten、以及孤儿/冗余清理批次)仍在待办中。