mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-02 15:27:33 +00:00
三条主线,由 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 失败。
166 lines
5.9 KiB
Go
166 lines
5.9 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|