oh-my-openagent 安全评审阻塞项全解:RPC 子进程树回收与构建产物完整性防护实战
【免费下载链接】oh-my-openagentOmO: Just type "mass ulw" keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent
导读
本文以 oh-my-openagent 仓库中 PR #6858 安全评审产生的阻塞项证据文档 security-blockers.md 为骨架,完整还原该次评审暴露出的四个安全缺陷及其修复方案:RPC 子进程树无法被彻底终止、生成式构建产物新鲜度标记可被伪造、相对可执行路径可被任务控制的工作目录劫持,以及相应的全量回归验证。读完本文,你将掌握进程组级终止、可复现压缩、防伪造完整性标记、路径规范化等一整套可复用到任意 Node/Bun 工具链的进程安全与供应链完整性加固手法,并能在本仓库中直接复现全部验证命令。
评审背景:四路评审、两个硬阻塞与四个安全项
这次安全评审针对 PR #6858 的 head 提交5e530b4e2885a9a38d0fe108f27fa5a055b37142展开,完整评审结论记录在 audit-findings.md 中。评审分为五条 lane:
| Lane | 结论 | 阻塞项 |
|---|---|---|
| 目标与约束(Goal and constraints) | FAIL | 生成式 bundle 冲突、缺当前 head CI、Windows 证据不可审计 |
| 代码质量(Code quality) | FAIL | 生成式 bundle 冲突;源码改动本身正确 |
| 安全与进程安全(Security and process safety) | PASS | 无安全阻塞 |
| 实操 QA(Hands-on QA) | FAIL | 缺当前 head CI;生产 RPC 路由证据失效 |
| 仓库与历史上下文 | FAIL | 生成式 bundle 冲突、缺当前 head CI |
评审同时确认了五个评审阻塞项(机械性不可合并、缺当前 head CI 运行、Windows 证据不可审计、task-rpc-e2e.mjs报告execution_mode: "process"未到达 RPC runner、PR 正文链接过期),并明确拒绝了两个非阻塞项:spawnProcess为测试桩扩展公开重导出类型属既有仓库 seam 模式、其他组件的 Windows pipe spawn 属后续范围而非本 PR 回归。
真正进入安全阻塞清单的是 security-blockers.md 中记录的四项,每项都遵循严格的 RED→GREEN 证据闭环:先给出失败复现命令与失败断言,再给出修复后的生产改动、验证命令与通过结果。
阻塞项一:RPC 子进程树的存活问题(进程组级终止)
RED:TERM-ignoring 后代进程存活
原始缺陷通过一条命令复现:
bun test packages/senpi-task/src/runners/rpc/terminate.test.ts观察结果:
- 直接 RPC 子进程在升级(escalation)后已退出;
- 忽略 SIGTERM 的后代进程仍然存活;
- 断言以
Expected: false、Received: true失败; - 套件结果 3 通过、1 失败。
测试夹具会启动一个真实子进程,由它再启动一个长生命周期后代进程,全程不经过 shell——这意味着问题不在 shell 包装层,而在进程树的终止策略本身。
GREEN:进程组拥有者 + SIGTERM/SIGKILL 有界升级
修复后的生产改动(对应 terminate.ts):
- POSIX 下 RPC 根进程以拥有进程组(owned process group)的方式启动;
- 终止时先对进程组发送 SIGTERM,再按有界延迟升级为 SIGKILL;
- Windows 下改用
taskkill.exe /PID <pid> /T /F且shell: false,避免经过 shell 转发引入注入面; - 测试通过
ps -o stat=区分"仍在执行"的进程与"已终止的 POSIX 僵尸进程",不依赖 sleep 等待。
从源码看,terminate.ts 的terminateRpcChild是 RPC 子进程终止的单一写入点(模块注释明确声明这是唯一允许向 RPC 子进程发送信号的模块)。其核心逻辑在 terminatePosixProcessGroup:
- 先通过
process.kill(-pid, 0)探测进程组是否存在(processGroupExists,ESRCH 视为不存在); - 进程组存在时,对
-pid发送 SIGTERM; waitForExitOrDelay用Promise.race在"子进程退出"与"默认 5 秒(DEFAULT_SIGKILL_DELAY_MS = 5_000)升级延迟"之间竞争;- 延迟到期后进程组仍存在,则升级发送 SIGKILL,并以 2 秒为上限、10 毫秒间隔轮询等待(
PROCESS_EXIT_OBSERVATION_TIMEOUT_MS/PROCESS_EXIT_OBSERVATION_INTERVAL_MS)。
对非组领导者调用方,terminateDirectChild 提供直系子进程的回退路径:同样的 SIGTERM→有界延迟→SIGKILL 升级。Windows 分支 terminateWindowsProcessTree 用shell: false直接 spawntaskkill.exe,并在任务结束后仍校验子进程是否退出、必要时补发 SIGKILL。
对应测试 terminate.test.ts 覆盖了五类场景:配合的子进程被 SIGTERM 优雅终止、TERM-ignoring 子进程在预算内升级为 SIGKILL、非组领导者子进程仍可终止、已退出子进程重复终止不抛错、以及"TERM-ignoring 后代进程随整棵树退出"(通过process-tree.mjs夹具读取后代 PID 后轮询验证)。
验证命令与结果
bun test packages/senpi-task/src/runners/rpc/terminate.test.ts bun test packages/senpi-task/src/runners bun run --cwd packages/senpi-task typecheck观察结果:终止套件 4 通过 0 失败;聚焦的 runner/终止套件 15 通过 0 失败;包类型检查 exit 0。
阻塞项二:可伪造的生成式 bundle 新鲜度标记(产物完整性双校验)
RED:注入 JS 主体后标记仍然匹配
复现命令:
bun test packages/omo-senpi/plugin/scripts/build-artifact.test.mjs观察结果:
- 向产物中注入一段 JavaScript 主体后,原本的 source digest 仍被保留;
- 可编辑主体的 digest 被重新计算;
artifactsMatch()返回true;- 断言以
Expected: false、Received: true失败。
这说明旧校验只验证了"标记中的 digest 与当前主体一致",而没有验证"当前主体与新鲜生成的主体字节一致"——攻击者可以在篡改主体后顺手重算并覆写标记,使校验形同虚设。
GREEN:双标记 + 字节一致性 + 固定压缩器版本
修复后的生产改动(对应 build-artifact.mjs):
- 门禁同时校验两个产物标记与其对应主体;
- 当前主体 digest 与新鲜生成的主体 digest 必须一致;
- 当前主体与新鲜生成的主体必须字节级一致(byte-identical);
- Bun 标识符混淆(identifier mangling)被替换为可复现的语法/空白压缩 + 固定
terser@5.44.0标识符混淆; - 压缩器版本被纳入构建设置 digest,压缩器一旦变动,标记立即失效。
从源码看,artifactsMatch 的判定链是:current.sourceDigest === expected.sourceDigest,且current.bodyDigest === digest(current.body)、expected.bodyDigest === digest(expected.body),最后要求两者 digest 相等且主体===字节相等。标记格式为// omo:<sourceDigest>:<bodyDigest>追加在产物头部(shebang 保留在字节 0,标记位于其下一行,digest 覆盖含 shebang 的整个主体),见 attachBuildMarker。digestBuildSources对 metafile 中所有输入文件按排序后的 key 逐个做 sha256 累加,并把构建脚本自身与build-artifact.mjs也纳入摘要。
可复现性保证落在 build-extension.mjs 的构建参数上:minifySyntax: true、minifyWhitespace: true、minifyIdentifiers: false、secondaryMinifier: "terser@5.44.0",CLI 形态对应--minify-syntax --minify-whitespace加--metafile,随后minifyBundle再做一次 terser 压缩。
验证命令与结果
bun test packages/omo-senpi/plugin/scripts/build-artifact.test.mjs npm exec --yes --package=bun@1.3.12 -- bash -c 'node packages/omo-senpi/plugin/scripts/build-extension.mjs --check' bun test packages/omo-senpi/src/bundle-size.test.ts观察结果:伪造回归测试 1 通过 0 失败;跨进程字节一致性检查 exit 0;omo.js产物 764,648 字节,低于 1,050,000 字节预算;bundle 体积与产物完整性门禁 2 通过 0 失败。
关于体积预算的演进背景,bundle-size.test.ts 的注释完整记录了从 700,000 逐步上调到 1,050,000 的历史:每次上调都对应明确的功能增量(如 memory 组件、lett-memory-parity-port、native-telemetry 波次),且始终遵守"不留意外膨胀空间、不引入新第三方依赖"的纪律——这是本仓库对单文件非拆分 bundle 拓扑的坚持:压缩是文件内语义保持变换,而代码拆分会产生兄弟 chunk,需要改动加载器拓扑,因此被排除。
阻塞项三:相对可执行路径替换(路径规范化防劫持)
RED:相对路径被任务控制的工作目录重解析
复现命令:
bun test packages/senpi-task/src/runners/rpc/spawn.test.ts观察结果:
- 相对形式的
SENPI_BIN只相对父进程校验后原样返回; - 相对形式的
PATH条目同样返回相对可执行路径; - 两条回归断言均失败,因为后续任务可控的
cwd可能把这些路径解析到完全不同的文件。
风险本质:RPC 子进程的cwd由任务 spec 提供(buildRpcSpawn中cwd: spec.cwd),若可执行路径保持相对,任务即可通过切换工作目录把"你以为的 senpi"替换成"任务放置的任意可执行文件",形成任意代码执行。
GREEN:全部候选路径绝对化 + 符号链接规范化 + 文件类型校验
修复后的生产改动(对应 senpi-launcher.ts 与 spawn.ts):
- 显式覆盖项(
SENPI_BIN)、兄弟候选、PATH 候选一律解析为绝对路径; realpathSync.native规范化符号链接与平台路径别名;statSync(...).isFile()在 spawn 前拒绝目录与非文件候选。
从源码看,canonicalExecutable 组合了realpathSync.native(resolve(candidate))与statSync(canonical).isFile()两道闸门,任何一步抛错即返回 null。resolveSenpiExecutable的候选优先级为:显式SENPI_BIN覆盖(即使其包版本与引擎不同也直接采用)→ 编译引擎运行时自身(isCompiledEngine,即 omo 单文件二进制内嵌引擎,spawnprocess.execPath即得到同版本同资产的后代)→ Bun 二进制旁边的兄弟 senpi → PATH 扫描。兄弟与 PATH 候选还会经过引擎版本一致性校验(passesEngineParity):候选的@code-yeongyu/senpimanifest 版本与运行中引擎版本不符时被跳过并告警,无 manifest 可读的候选则保留。
配套的 Windows 细节在 normalizeSenpiLauncher:非.exe后缀候选被识别为 npm shim,回溯其相邻node_modules/@code-yeongyu/senpi/dist/cli.js;而 isRunningCompiledEngine 防止把"无扩展名的编译引擎自身"误判为 shim 而丢弃唯一保证版本匹配的候选。
验证命令与结果
观察结果:可执行解析套件 23 通过 0 失败;两类相对路径场景均返回规范化绝对路径;senpi-task 类型检查与 LSP 诊断干净。验证覆盖了SENPI_BIN缺失路径不静默回退、空 PATH 不命中、bun 兄弟缺失时透传回退、版本不匹配被拒绝、stale 与匹配 PATH 候选并存时匹配者胜出、以及显式覆盖优先于版本校验等分支。
阻塞项四:进程包含边界的诚实声明
修复后的terminateRpcChild契约在 terminate.ts 与证据文档中有明确边界声明:
- POSIX:回收其拥有的 RPC 进程组;
- Windows:通过
taskkill /T /F回收可发现的进程树; - 非组领导者调用方:直系子进程回退;
- 真实 TERM-ignoring 子进程与后代进程夹具无需 sleep 即可通过。
文档同时明确强调:该 runner 不是操作系统沙箱。已被信任可以访问 Agent 文件系统、网络与凭据的代码,可以刻意守护进程化(daemonize)到新会话,或制造不可逆的外部副作用;阻止这些需要独立的沙箱/容器架构,不属于本 PR 子进程生命周期契约的承诺范围。平台 QA 要求通过"证明拥有的 RPC 树退出、且无记录的子 PID 残留"来满足——这个边界声明本身就是安全评审的重要产物:它防止后续把进程终止误当作沙箱隔离来使用。
全量回归:修复后的 Senpi 完整门禁
所有安全修复落地后,运行全量门禁:
bun run test:senpi观察结果:1,715 通过;1 个有意的 Windows-only 生产驱动跳过;0 失败;1,716 个测试共 4,844 条断言,耗时 148.41 秒。文档同时确认:全程不保留任何 provider token、凭据体、授权头、私钥或原始环境转储。
复现与验证命令速查
| 目的 | 命令 |
|---|---|
| 进程树终止回归 | bun test packages/senpi-task/src/runners/rpc/terminate.test.ts |
| RPC runner 全量 | bun test packages/senpi-task/src/runners |
| 可执行路径解析回归 | bun test packages/senpi-task/src/runners/rpc/spawn.test.ts |
| senpi-task 类型检查 | bun run --cwd packages/senpi-task typecheck |
| 产物伪造回归 | bun test packages/omo-senpi/plugin/scripts/build-artifact.test.mjs |
| 跨进程字节一致性检查 | npm exec --yes --package=bun@1.3.12 -- bash -c 'node packages/omo-senpi/plugin/scripts/build-extension.mjs --check' |
| bundle 体积与完整性门禁 | bun test packages/omo-senpi/src/bundle-size.test.ts |
| Senpi 全量门禁 | bun run test:senpi |
总结:四条可复用的安全加固经验
- 进程树终止要按进程组治理:POSIX 用
process.kill(-pid, ...)面向进程组、SIGTERM 后按有界延迟升级 SIGKILL;Windows 用taskkill /T /F且shell: false;并把信号发送收敛到单一模块,杜绝多方抢发。 - 产物完整性校验要防"自洽伪造":任何"标记与主体一致"的校验都必须同时叠加"当前主体与新鲜构建字节一致"的校验,digest 要覆盖含 shebang 的完整主体,压缩器版本要钉死并纳入构建摘要,保证可复现。
- 可执行路径在 spawn 前必须绝对化:
realpathSync.native+statSync.isFile()双闸门,杜绝任务可控cwd造成的相对路径劫持;候选来源(覆盖项/兄弟/PATH/引擎自身)要有明确优先级与版本一致性过滤。 - 安全边界要诚实声明:进程终止不是沙箱,二者不可混用;明确契约范围既能防止误用,也是评审可审计性的基础。
上述全部实现与测试均可直接在本仓库中检视与复现:进程终止见 terminate.ts 与 terminate.test.ts,可执行路径解析见 senpi-launcher.ts 与 spawn.test.ts,产物完整性见 build-artifact.mjs、build-extension.mjs 与 build-artifact.test.mjs,完整评审背景见 audit-findings.md。
【免费下载链接】oh-my-openagentOmO: Just type "mass ulw" keyword with your prompt. Now you are the master of graph engineering.项目地址: https://gitcode.com/gh_mirrors/oh/oh-my-openagent
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考