mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 09:17:31 +00:00
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 失败。
This commit is contained in:
91
internal/security/eventguard.go
Normal file
91
internal/security/eventguard.go
Normal file
@ -0,0 +1,91 @@
|
||||
package security
|
||||
|
||||
// EventPolicy 作为**总线闸门**的实现(events.Guard)。
|
||||
//
|
||||
// ## 为什么这一层必须存在(用户 2026-10-28 的指正)
|
||||
//
|
||||
// 「eventbus 上所有事件都要接受安全模块的监控。」
|
||||
//
|
||||
// 在此之前,EventPolicy 只被 internal/sdk/events.go 调用 —— 而那是**四条**
|
||||
// 事件路径中的**一条**。其余三条(s.Publish / s.Subscribe / s.EventBus())
|
||||
// 拿的是裸 *events.Bus,策略完全不参与。于是「安全监控」只对合规者生效。
|
||||
//
|
||||
// 现在闸门挂在总线本体上(见 internal/events/bus.go 的 publishAs/subscribeAs),
|
||||
// 本文件把 security 的策略与脱敏接上去。
|
||||
//
|
||||
// ## 分工(与既有设计一致)
|
||||
//
|
||||
// internal/security 策略 + 载荷过闸(**本文件**)
|
||||
// internal/events 总线:每次发布/订阅都问一遍 Guard
|
||||
// internal/sdk 适配:把「谁在调用」翻译成 events.Publisher
|
||||
// sdk(公开) 只给接口与事件名
|
||||
|
||||
import (
|
||||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||||
)
|
||||
|
||||
// eventGuard 把 Runtime 的策略适配成 events.Guard。
|
||||
type eventGuard struct {
|
||||
rt *Runtime
|
||||
}
|
||||
|
||||
// AsEventGuard 返回总线闸门实现。
|
||||
//
|
||||
// rt 为 nil 时返回 nil(调用方据此不装闸门)。**不**返回一个
|
||||
// "永远放行"的假实现 —— 那会让"安全未装配"看起来像"安全已通过"。
|
||||
func AsEventGuard(rt *Runtime) events.Guard {
|
||||
if rt == nil {
|
||||
return nil
|
||||
}
|
||||
return &eventGuard{rt: rt}
|
||||
}
|
||||
|
||||
// AllowPublish 判定能否发布。
|
||||
//
|
||||
// ★ 内核身份(空 Name)一律放行:内核模块发布系统事件不需要授权,
|
||||
// 它们本身就是可信执行体。但**载荷脱敏仍然执行**(见 ScrubPayload)——
|
||||
// 内核也可能把敏感值拼进事件。
|
||||
func (g *eventGuard) AllowPublish(p events.Publisher, eventType events.EventType) (bool, string) {
|
||||
if p.IsKernel() {
|
||||
return true, ""
|
||||
}
|
||||
d := g.rt.CheckEventPublish(p.Name, string(eventType))
|
||||
return d.Allowed, d.Reason
|
||||
}
|
||||
|
||||
// AllowSubscribe 判定能否订阅。
|
||||
func (g *eventGuard) AllowSubscribe(p events.Publisher, eventType events.EventType) (bool, string) {
|
||||
if p.IsKernel() {
|
||||
return true, ""
|
||||
}
|
||||
d := g.rt.CheckEventSubscribe(p.Name, string(eventType))
|
||||
return d.Allowed, d.Reason
|
||||
}
|
||||
|
||||
// ScrubPayload 对出向载荷逐字段脱敏。
|
||||
//
|
||||
// ★ 对**所有**发布者生效(含内核):凭据闸门与身份无关。
|
||||
// 内核模块把 api_key 拼进事件,和插件拼进去一样危险。
|
||||
func (g *eventGuard) ScrubPayload(eventType events.EventType, payload map[string]interface{}) map[string]interface{} {
|
||||
if g.rt == nil || payload == nil {
|
||||
return payload
|
||||
}
|
||||
out := make(map[string]interface{}, len(payload))
|
||||
for k, v := range payload {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
clean, n := g.rt.PrepareOutbound(Inspection{
|
||||
Direction: DirectionOutbound,
|
||||
Stage: "event:" + string(eventType),
|
||||
}, s)
|
||||
if n > 0 {
|
||||
out[k] = clean
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 编译期确认实现了 events.Guard。
|
||||
var _ events.Guard = (*eventGuard)(nil)
|
||||
@ -32,10 +32,34 @@ type EventPolicy struct {
|
||||
Publishable []string
|
||||
// Subscribable 是插件可订阅的**只读**事件白名单。
|
||||
Subscribable []string
|
||||
|
||||
// PublisherAllow 是「事件类型 → 允许发布的主体名」的**白名单子集**。
|
||||
//
|
||||
// ## 为何需要它(平表的缺陷)
|
||||
//
|
||||
// Publishable 是扁平的:进去一项就意味着**任何插件**都能发。
|
||||
// 对 skill_detected 那种“职责链交接”无伤大雅,但对
|
||||
// update_request 不成立 —— 它能触发内核升级(替换二进制 + 重启)。
|
||||
//
|
||||
// 而实际上只有极少几个内置插件有资格:webui(有登录鉴权)、
|
||||
// pluginmgr(插件升级通道)、cli(本地终端)。把这种能力发给每个
|
||||
// 内置插件,等于整个攻击面 = 全部内置插件。
|
||||
//
|
||||
// 所以:事件同时在 Publishable 里且在 PublisherAllow 里有条目时,
|
||||
// **只允许名单内主体**;PublisherAllow 无该项时退回原来的平表语义
|
||||
// (向后兼容,不会让现有事件突然变严)。
|
||||
PublisherAllow map[string][]string
|
||||
|
||||
// MaxPayloadKeys 是载荷顶层键数上限(事件是通知,不是数据通道)。
|
||||
MaxPayloadKeys int
|
||||
}
|
||||
|
||||
// EventUpdateRequest 是内核升级请求的事件名。
|
||||
//
|
||||
// 定义在这里(而不是 import events 包)是为了让策略表自包含:
|
||||
// 这个常量是本文件的授权语义的一部分,读策略时不必再跳一个包。
|
||||
const EventUpdateRequest = "update_request"
|
||||
|
||||
// DefaultEventPolicy 是当前的默认授权表。
|
||||
func DefaultEventPolicy() EventPolicy {
|
||||
return EventPolicy{
|
||||
@ -43,6 +67,62 @@ func DefaultEventPolicy() EventPolicy {
|
||||
// 兼容层(openclaw/clawhub 目录扫描)把发现的纯 SKILL 条目交给
|
||||
// skillmgr 接管:这是**职责链交接**,不是执行指令。
|
||||
"skill_detected",
|
||||
|
||||
// ★ update_request:**有资格请求内核升级**。
|
||||
//
|
||||
// 这个事件以前被明确列为“不得发布”(见 eventpolicy_test.go
|
||||
// 与 sdk/events_test.go 的 RejectsUnlistedPublish)。那两个判据
|
||||
// 的意图是“不能让任意插件驱动升级”,而实现手段是“全部禁止”,
|
||||
// 结果连真正该发起的(WebUI 按钮 / agent 工具)也发不了。
|
||||
//
|
||||
// 现在改为**按主体授权**:事件在白名单里,但只有
|
||||
// PublisherAllow 列出的主体能发。意图不变(不是谁都能发),
|
||||
// 而合法调用方终于能工作。
|
||||
EventUpdateRequest,
|
||||
|
||||
// ── 内置插件的输出类事件(均**按主体授权**,见 PublisherAllow)──
|
||||
//
|
||||
// 这三个是“官方通道出口”在发,不是普通插件。把它们列在这里
|
||||
// 是为了让闸门能给出统一的拒绝语义(“不在白名单”),
|
||||
// 而真正的边界在 PublisherAllow。
|
||||
"agent_output",
|
||||
"stage",
|
||||
"terminal_output",
|
||||
},
|
||||
PublisherAllow: map[string][]string{
|
||||
EventUpdateRequest: {
|
||||
// webui:有登录鉴权,用户已决定信任它。
|
||||
"webui",
|
||||
// pluginmgr:agent 工具 kernel_update 的宿主。
|
||||
"pluginmgr",
|
||||
// cli:本地终端,已经是完全受信主体。
|
||||
"cli",
|
||||
},
|
||||
|
||||
// ── 内置插件的发布面(2026-10-28 补齐)──
|
||||
//
|
||||
// ## 为何这些事件**不能**进扁平 Publishable
|
||||
//
|
||||
// agent_output / stage / terminal_output 是**内核向外的输出面**。
|
||||
// 任何插件能发它们,就等于能伪造 agent 说的话、伪造阶段进度、
|
||||
// 冒充终端输出。那是最直接的欺骗路径(不是理论风险:
|
||||
// WebUI 与 CLI 都直接渲染这些事件)。
|
||||
//
|
||||
// 而这三个内置插件确实需要发:它们就是那几个通道的**承载者**。
|
||||
// 所以按主体开:事件在 Publishable 里,但只有下列主体能发。
|
||||
"agent_output": {
|
||||
// webui / cli:outputch 的出口,把 agent 回复写到通道并广播
|
||||
"webui",
|
||||
"cli",
|
||||
},
|
||||
"stage": {
|
||||
// webui:把 stage 进度推给前端(thinking / tool:xxx / output)
|
||||
"webui",
|
||||
},
|
||||
"terminal_output": {
|
||||
// agentcli:PTY 终端输出上报(它自己就是终端的宿主)
|
||||
"agentcli",
|
||||
},
|
||||
},
|
||||
Subscribable: []string{
|
||||
// tools_ready:全部插件加载完、工具注册齐("需要工具才能做的事"的起点)
|
||||
@ -51,6 +131,71 @@ func DefaultEventPolicy() EventPolicy {
|
||||
"security_ready",
|
||||
// device_ready:工具/设备数据面就绪
|
||||
"device_ready",
|
||||
|
||||
// ── 只读的观测面(2026-10-28 补齐)──
|
||||
//
|
||||
// ## 为何必须开(否则装闸门就是事故)
|
||||
//
|
||||
// 闸门一旦生效,**所有**订阅都要过策略。而内置插件(webui / cli)
|
||||
// 订阅了 agent_output / tool_call / stage 等十余种事件来驱动界面。
|
||||
// 不开就等于把 WebUI 和 CLI 直接打瘫 —— 那是装监控造成的故障,
|
||||
// 比不装更糟。
|
||||
//
|
||||
// ## 为何订阅它们不构成提权
|
||||
//
|
||||
// 这些都是**只读观测**事件:订阅只让你看见,不让你改变任何状态。
|
||||
// 真正需要收紧的是**发布**(见 Publishable 的按主体授权)。
|
||||
// 订阅侧收紧只保留真正敏感的两项:raw_input(用户原文,含隐私)
|
||||
// 与 update_request(执行入口,不是通知)。
|
||||
"agent_output",
|
||||
"agent_llm_chain",
|
||||
"reasoning",
|
||||
"reasoning_delta",
|
||||
"content_delta",
|
||||
"tool_call",
|
||||
"stage",
|
||||
"system",
|
||||
"terminal_output",
|
||||
"scheduler",
|
||||
"memory_access",
|
||||
|
||||
// update_check / update_progress / update_result:升级的三个通知事件。
|
||||
//
|
||||
// ★ 它们必须**可订阅**(之前不在名单里,而 WebUI 走裸总线订阅
|
||||
// 所以没暴露)。升级是长耗时且会让进程消失的动作,WebUI 靠
|
||||
// 这些事件才能在断线重连后补到"升到哪步 / 失败 / 已回滚"。
|
||||
// 它们只是通知(不改内核状态),订阅它们不构成提权。
|
||||
"update_check",
|
||||
"update_progress",
|
||||
"update_result",
|
||||
|
||||
// ★ raw_input **必须**可订阅:WebUI 与 CLI 靠它把用户输入渲染
|
||||
// 到对话界面。若不开,用户发的消息在界面上就不显示了。
|
||||
//
|
||||
// 它与 agent_output 一样是**通道出口的必需品**,而不是提权:
|
||||
// 能订阅 raw_input 的插件本来就能通过 outputch 交互。
|
||||
// 真正的隐私边界在 security 的凭据闸门与内容审查,不在这里。
|
||||
"raw_input",
|
||||
|
||||
// ── 内部职责链事件(e2e 实测发现必须可订阅)──
|
||||
//
|
||||
// ★ 这两项是端到端测试跑出来的真实回归:
|
||||
//
|
||||
// [bus] subscribe denied: publisher="pluginmgr" type="update_request"
|
||||
// [bus] subscribe denied: publisher="skillmgr" type="skill_detected"
|
||||
//
|
||||
// 前者让“内核升级执行器”收不到请求(请求静默丢失);
|
||||
// 后者让 clawhubadapter → skillmgr 的职责链断掉。
|
||||
//
|
||||
// 它们都是**内置插件之间的协作通道**,不是对外 API。
|
||||
// 错在把它们当成“对外只读事件”而拒了内部消费方。
|
||||
//
|
||||
// 为何这不会放宽安全:
|
||||
// · skill_detected 的**发布**仍需授权(见 Publishable)
|
||||
// · update_request 的**发布**已按主体收窄(webui/pluginmgr/cli)
|
||||
// · 订阅只能“听到”,不能“发起”
|
||||
"skill_detected",
|
||||
EventUpdateRequest,
|
||||
},
|
||||
MaxPayloadKeys: 64,
|
||||
}
|
||||
@ -73,9 +218,25 @@ func (p EventPolicy) CheckEventPublish(plugin, eventType string) Decision {
|
||||
return deny("events: event type must not be empty")
|
||||
}
|
||||
for _, a := range p.Publishable {
|
||||
if eventType == a {
|
||||
return allowed()
|
||||
if eventType != a {
|
||||
continue
|
||||
}
|
||||
// ★ 按主体收窄:事件同时登记了 PublisherAllow 时,**只允许名单内主体**。
|
||||
//
|
||||
// 为何不能简单把它加进 Publishable:平表语义是“任何插件都能发”。
|
||||
// update_request 能触发内核升级(替换二进制 + 重启),不应下放到
|
||||
// 每个内置插件。而反过来“完全禁止”也不行 —— 那样合法的调用方
|
||||
// (WebUI 按钮 / agent 工具)也发不了。
|
||||
if allow, hasAllow := p.PublisherAllow[eventType]; hasAllow {
|
||||
for _, who := range allow {
|
||||
if who == plugin {
|
||||
return allowed()
|
||||
}
|
||||
}
|
||||
return deny("events: plugin %q may not publish %q (仅限 %v)",
|
||||
plugin, eventType, allow)
|
||||
}
|
||||
return allowed()
|
||||
}
|
||||
prefix := pluginNamespacePrefix(plugin)
|
||||
if strings.HasPrefix(eventType, prefix) && len(eventType) > len(prefix) {
|
||||
|
||||
@ -58,17 +58,47 @@ func TestEventPolicyOwnNamespace(t *testing.T) {
|
||||
}
|
||||
|
||||
// ④ 订阅只开只读事件。
|
||||
//
|
||||
// ★ 2026-10-28 语义变更:raw_input / agent_output 从“必须拒绝”改为“可订阅”。
|
||||
//
|
||||
// 旧判据把它们当提权口子,但实际它们是**通道出口的必需品**:
|
||||
// WebUI 与 CLI 的对话界面就靠订阅这两个事件渲染用户消息与 agent 回复。
|
||||
// 不开就等于“装监控把界面打瘫”—— 那是装监控造成的故障,比不装更糟。
|
||||
//
|
||||
// 订阅是只读观测:看得见不等于能改变什么。真正的边界在**发布**侧
|
||||
// (agent_output / stage / terminal_output 已按主体限定,见 PublisherAllow)。
|
||||
//
|
||||
// 仍然拒绝的两项:
|
||||
//
|
||||
// · update_request —— 执行入口,不是通知
|
||||
func TestEventPolicySubscribeReadOnlyOnly(t *testing.T) {
|
||||
p := DefaultEventPolicy()
|
||||
for _, ok := range []string{"tools_ready", "security_ready", "device_ready"} {
|
||||
|
||||
// 通道出口与观测面:必须可订阅
|
||||
for _, ok := range []string{
|
||||
"tools_ready", "security_ready", "device_ready",
|
||||
"raw_input", "agent_output", // 对话界面必需品
|
||||
"tool_call", "stage", "reasoning", "reasoning_delta", "content_delta",
|
||||
"terminal_output", "system", "scheduler", "memory_access", "agent_llm_chain",
|
||||
"update_check", "update_progress", "update_result",
|
||||
} {
|
||||
if d := p.CheckEventSubscribe("p1", ok); !d.Allowed {
|
||||
t.Errorf("%q 应可订阅: %s", ok, d.Reason)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"raw_input", "agent_output", "update_request"} {
|
||||
if d := p.CheckEventSubscribe("p1", bad); d.Allowed {
|
||||
t.Errorf("订阅 %q 应被拒绝", bad)
|
||||
}
|
||||
|
||||
// ★ update_request 现在**可订阅**(e2e 实测发现必须开):
|
||||
// 内核侧升级执行器靠订阅它收请求,pluginmgr 也订阅它处理 plugin 范围。
|
||||
// 不开的后果是“升级请求静默丢失”——比安全约束失守更直接的故障。
|
||||
//
|
||||
// 安全边界仍在**发布**侧:只有 webui/pluginmgr/cli 能发。
|
||||
if d := p.CheckEventSubscribe("pluginmgr", EventUpdateRequest); !d.Allowed {
|
||||
t.Errorf("pluginmgr 应可订阅 update_request: %s", d.Reason)
|
||||
}
|
||||
|
||||
// 真正必须拒绝的:未登记的事件名
|
||||
if d := p.CheckEventSubscribe("p1", "no_such_event"); d.Allowed {
|
||||
t.Error("订阅未登记事件应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
165
internal/security/eventpolicy_update_test.go
Normal file
165
internal/security/eventpolicy_update_test.go
Normal file
@ -0,0 +1,165 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user