Files
HomeAgent/internal/security/eventpolicy_update_test.go
JianFeeeee ba281a855a feat(update): 差分 OTA + 安装形态分派 + 事件总线统一受安全监控
三条主线,由 e2e 实测驱动。

## 一、差分 OTA(internal/update/delta.go)

实测:改一行逻辑,88MB 的二进制逐字节只差 80 字节,而 OTA 一直下整包。

用 rsync 族的滚动哈希块匹配(4KB 粒度),不是 bsdiff —— 后者需 17×N
峰值内存(88MB → 1.5GB),而升级恰可能发生在内存紧张时。

真实二进制实测:88,036,728 字节 → 补丁 77,316 字节(0.088%)。

三道闸:应用前验旧文件 sha256(错基线会静默产出垃圾)、应用后验结果
sha256、COPY/LITERAL 越界防护。任何失败**退回整包**,不向上抛 ——
差分是优化,不是可用性前提。

接入:Source.Resolve 优先试 delta;CheckRelease 按 -from-<commit8>.delta
匹配(不猜基线);CLI 新增 -make-delta;打包脚本 build_delta(需
PREVIOUS_HOMED),delta 进 SHA256SUMS。

## 二、安装形态分派(internal/update/install.go + channel.go)

内核对「能不能自己升级」此前只有一个答案:直接替换二进制。而 deb/rpm
形态下那样做会让 dpkg/rpm 数据库与实际内容不一致(下次 apt upgrade
覆盖回旧版,或 dpkg 拒绝后续升级)。

新增探测:deb/rpm/tar/container/source 五类形态 + 变体(full/server/
client/toolchain)+ 目录写权限。判据全部来自可观测系统事实(dpkg 的
info 目录、容器标记文件),不做路径推断。

三端统一走 PlanUpgrade 分派;形态闸门放在**下载 880MB 之前**。

## 三、事件总线统一受安全监控(internal/events + security)

用户指正:「eventbus 上所有事件都要接受安全模块的监控」。

此前 EventPolicy 只被 internal/sdk/events.go 调用,而那是四条路径中的
一条。s.Publish / s.Subscribe / s.EventBus() 拿的是裸 *events.Bus,
任何插件都能绕过策略发任意事件(含伪造内核事件)。

现在闸门在 Bus.Publish/Subscribe 本体上(Guard 接口注入,避免 events
↔ security 成环),四条路径全部覆盖。sdk.EventBus() 改返回带身份的
BusHandle。内核身份免授权,但载荷照样过凭据闸门。

白名单按主体收窄:agent_output/stage/terminal_output 只允许承载者
(webui/cli/agentcli)发布 —— 它们是内核向外的输出面,谁都能发就等于
能伪造 agent 说的话。

## e2e 暴露并修掉的三个真实回归

	[bus] subscribe denied: pluginmgr type=update_request
	[bus] subscribe denied: skillmgr type=skill_detected

前两个让升级请求静默丢失、职责链交接断掉。内部协作事件必须可订阅
(安全边界在发布侧,订阅只是「听得到」)。

## 顺带的止血修复

- 回滚判据从 `--version` 改为 digest:旧内核(1.3.x 及以前)没有该
  flag,而它恰是最常见的回滚目标 —— 原逻辑让「从旧版升级失败」永久
  无法回滚。
- CopyFile 补 fsync(替换 homed 本体时掉电会留半截文件)。
- restore 完整还原才清 marker(原先先清再还原,还原失败就失去第二次
  自愈机会)。
- 回滚时移除升级中新增的 depgraph(否则「旧 homed + 新 depgraph」
  会因 digest 不匹配崩溃循环)。
- homed-upgrade 不再硬编码 systemctl restart(丢 guard 状态、unit 名
  漂移);改用内核已有的 restartCh → exit 42 语义。
- homed-upgrade 不再用 CGO_ENABLED=0 编却依赖 go-sqlite3(stub 下
  静默失效,该路径从未工作过);改用 internal/config。
- 服务单元补 User=hologramagent + 三条安装路径补建用户(此前发行物里
  完全没有落实降权)。
- rpm 的 fpm 调用补 --after-install(此前 rpm 装完根本不建用户)。

## 判据

新增 30 条:差分 8(往返/压缩比/错基线/截断/篡改/边界)、通道 8、
执行器 6、事件闸门 8、按主体授权 8。全仓 go test ./... 0 失败。
2026-10-29 09:54:55 +08:00

166 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package security
// update_request 的**按主体授权**判据。
//
// ## 背景:这里曾经是“全禁”
//
// 旧的判据把 update_request 列进 RejectsUnlistedPublish,意图是
// “不能让任意插件驱动内核升级”。但实现手段是“全部禁止”,于是连
// 真正该发起的地方(WebUI 按钮、agent 工具)也发不了 —— 而内核侧
// 的执行器(internal/update)已经在等这个事件。
//
// 现在改为按主体授权:事件在白名单里,但只有 PublisherAllow 列出的
// 主体能发。意图不变(不是谁都能发),合法调用方终于能工作。
//
// 这组判据钉住三件事:
// · 名单内主体能发
// · 名单外主体**不能**发(这才是安全边界)
// · 订阅侧不受影响(通知类事件,订阅不构成提权)
import "testing"
// ① 名单内的主体可以发布 update_request。
func TestUpdateRequest_AllowedPublishers(t *testing.T) {
p := DefaultEventPolicy()
for _, who := range []string{"webui", "pluginmgr", "cli"} {
if d := p.CheckEventPublish(who, EventUpdateRequest); !d.Allowed {
t.Errorf("%s 应可发布 update_request,实际被拒: %s", who, d.Reason)
}
}
}
// ② 名单外的主体必须被拒绝 —— 这是安全边界本身。
func TestUpdateRequest_RejectsOtherPlugins(t *testing.T) {
p := DefaultEventPolicy()
for _, who := range []string{"files", "seq", "agentcli", "evil", "mcp", "kbtree"} {
d := p.CheckEventPublish(who, EventUpdateRequest)
if d.Allowed {
t.Errorf("★ %s 不应能发布 update_request(能发就等于能触发内核升级)", who)
}
if d.Reason == "" {
t.Errorf("拒绝 %s 时必须给出理由", who)
}
}
}
// ③ 拒绝理由要指出**允许名单**(否则调用方只能猜)。
func TestUpdateRequest_DenyReasonNamesAllowlist(t *testing.T) {
p := DefaultEventPolicy()
d := p.CheckEventPublish("evil", EventUpdateRequest)
if d.Allowed {
t.Fatal("应被拒绝")
}
for _, want := range []string{"webui", "pluginmgr", "cli"} {
if !containsStr(d.Reason, want) {
t.Errorf("拒绝理由应列出允许主体 %q,实际: %s", want, d.Reason)
}
}
}
// ④ 无 PublisherAllow 条目的事件仍走平表语义(向后兼容)。
//
// 判据:skill_detected 没有被收窄,任何插件都能发 —— 否则会把
// 现有兼容层的职责链交接弄坏。
func TestSkillDetected_StillFlatAllowlist(t *testing.T) {
p := DefaultEventPolicy()
for _, who := range []string{"clawhubadapter", "any-other-plugin"} {
if d := p.CheckEventPublish(who, "skill_detected"); !d.Allowed {
t.Errorf("%s 应可发布 skill_detected(平表语义): %s", who, d.Reason)
}
}
}
// ⑤ update 的三个通知事件可订阅(WebUI 靠它们显示升级进度)。
func TestUpdateEvents_Subscribable(t *testing.T) {
p := DefaultEventPolicy()
for _, evt := range []string{"update_check", "update_progress", "update_result"} {
if d := p.CheckEventSubscribe("webui", evt); !d.Allowed {
t.Errorf("应可订阅 %q: %s", evt, d.Reason)
}
}
}
// ⑥ update_request **必须可订阅**:内核执行器与 pluginmgr 靠它收请求。
//
// ★ 这条判据的方向在 e2e 后反转了(2026-10-28)。
//
// 原先写的是“不可订阅”,理由是“它是执行入口,不是通知”。但端到端实测
// 立刻打脸:
//
// [bus] subscribe denied: publisher="pluginmgr" type="update_request"
//
// 后果是升级请求**静默丢失** —— 请求方以为发了,执行器永远收不到。
// 这比“订阅侧略宽”严重得多。
//
// 安全边界在**发布**侧(只有 webui/pluginmgr/cli 能发),订阅只是“听得到”。
func TestUpdateRequest_SubscribableByInternalConsumers(t *testing.T) {
p := DefaultEventPolicy()
for _, who := range []string{"pluginmgr", "webui"} {
if d := p.CheckEventSubscribe(who, EventUpdateRequest); !d.Allowed {
t.Errorf("%s 应可订阅 update_request(升级执行器依赖它): %s", who, d.Reason)
}
}
// 但**发布**仍严格收窄
if d := p.CheckEventPublish("seq", EventUpdateRequest); d.Allowed {
t.Error("★ seq 不应能发布 update_request")
}
}
func containsStr(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
// ⑦ 内置插件的输出面**只允许承载者**发布。
//
// 这是本次补齐的核心边界:agent_output / stage / terminal_output 是
// 内核向外的输出面,任何插件能发就等于能伪造 agent 的话。
// 它们必须按主体授权,**不能**落进扁平白名单。
func TestBuiltinOutputEvents_RestrictedToOwners(t *testing.T) {
p := DefaultEventPolicy()
allow := map[string][]string{
"agent_output": {"webui", "cli"},
"stage": {"webui"},
"terminal_output": {"agentcli"},
}
deny := map[string][]string{
"agent_output": {"files", "seq", "clawhubadapter", "evil"},
"stage": {"files", "cli", "evil"},
"terminal_output": {"webui", "cli", "evil"},
}
for evt, owners := range allow {
for _, who := range owners {
if d := p.CheckEventPublish(who, evt); !d.Allowed {
t.Errorf("%s 应可发布 %s: %s", who, evt, d.Reason)
}
}
}
for evt, others := range deny {
for _, who := range others {
if d := p.CheckEventPublish(who, evt); d.Allowed {
t.Errorf("★ %s 不应能发布 %s(伪造输出面)", who, evt)
}
}
}
}
// ⑧ 对话界面依赖的观测事件可订阅(装闸门不能把界面打瘫)。
func TestChatObservabilityEvents_Subscribable(t *testing.T) {
p := DefaultEventPolicy()
for _, evt := range []string{
"raw_input", "agent_output", "tool_call", "stage",
"reasoning", "reasoning_delta", "content_delta",
"terminal_output", "system", "scheduler", "memory_access",
} {
if d := p.CheckEventSubscribe("webui", evt); !d.Allowed {
t.Errorf("★ webui 应可订阅 %q(对话/状态界面依赖它): %s", evt, d.Reason)
}
}
}