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 失败。
309 lines
12 KiB
Go
309 lines
12 KiB
Go
package security
|
||
|
||
// 事件总线策略:**谁能发/订阅什么事件**。
|
||
//
|
||
// ## 为什么它属于 security 模块(用户 2026-10-27)
|
||
//
|
||
// 事件的边界不是"总线的一个参数",而是**授权**:一个插件能发 skill_detected
|
||
// 就等于能驱动 skillmgr 接管;能发 update_request 就等于能触发内核升级流程。
|
||
// 这类判断和工具黑白名单是同一类东西,理应放在同一处(security),而不是
|
||
// 散在 SDK 实现里 —— 否则策略会出现两份真相,且换实现就换口径。
|
||
//
|
||
// 分工:
|
||
//
|
||
// internal/security 策略(本文件)+ 载荷过闸(PrepareOutbound/ScrubInbound)
|
||
// internal/sdk 总线适配:查策略 → 发/订 → 过闸
|
||
// sdk(公开) 只给接口与事件名,不给策略
|
||
//
|
||
// ## 为什么"关闭 security"不影响本策略
|
||
//
|
||
// `core.security.enabled=false` 关掉的是**凭据替换**(需要主密钥),
|
||
// 而"插件不许发 raw_input"这类授权与密钥无关。所以 EventPolicy 挂在
|
||
// Runtime 上,Disabled Runtime 同样持有默认策略 —— 关安全不等于开权限。
|
||
|
||
import "strings"
|
||
|
||
// EventPolicy 是事件总线的授权策略。
|
||
type EventPolicy struct {
|
||
// Publishable 是插件可发布的事件类型白名单。
|
||
//
|
||
// ★ 加一项前先问:这个事件会不会让插件**间接驱动内核行为**?
|
||
// 会的话就不能加——事件是通知,不是未授权的执行入口。
|
||
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{
|
||
Publishable: []string{
|
||
// 兼容层(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:全部插件加载完、工具注册齐("需要工具才能做的事"的起点)
|
||
"tools_ready",
|
||
// security_ready:安全闸门装配完成(含"是否真的在生效")
|
||
"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,
|
||
}
|
||
}
|
||
|
||
// pluginNamespacePrefix 返回某个插件**自己的**事件命名空间前缀。
|
||
//
|
||
// 插件的私有事件必须在这个前缀下(`plugin.<name>.<后缀>`),
|
||
// 这样"两个插件协作"不需要新增白名单,也不可能冒用别人的名字。
|
||
func pluginNamespacePrefix(plugin string) string {
|
||
return "plugin." + plugin + "."
|
||
}
|
||
|
||
// CheckEventPublish 判定插件能否发布某个事件。
|
||
func (p EventPolicy) CheckEventPublish(plugin, eventType string) Decision {
|
||
if plugin == "" {
|
||
return deny("events: publisher name is empty")
|
||
}
|
||
if eventType == "" {
|
||
return deny("events: event type must not be empty")
|
||
}
|
||
for _, a := range p.Publishable {
|
||
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) {
|
||
return allowed()
|
||
}
|
||
return deny("events: plugin %q may not publish %q (allowlist: %v, own namespace: %s*)",
|
||
plugin, eventType, p.Publishable, prefix)
|
||
}
|
||
|
||
// CheckEventSubscribe 判定插件能否订阅某个事件。
|
||
func (p EventPolicy) CheckEventSubscribe(plugin, eventType string) Decision {
|
||
if plugin == "" {
|
||
return deny("events: subscriber name is empty")
|
||
}
|
||
for _, a := range p.Subscribable {
|
||
if eventType == a {
|
||
return allowed()
|
||
}
|
||
}
|
||
return deny("events: plugin %q may not subscribe to %q (allowlist: %v)",
|
||
plugin, eventType, p.Subscribable)
|
||
}
|
||
|
||
// CheckEventPayloadSize 判定载荷规模(键数)是否在允许范围。
|
||
func (p EventPolicy) CheckEventPayloadSize(keys int) Decision {
|
||
max := p.MaxPayloadKeys
|
||
if max <= 0 {
|
||
max = 64
|
||
}
|
||
if keys > max {
|
||
return deny("events: payload has %d keys, limit is %d", keys, max)
|
||
}
|
||
return allowed()
|
||
}
|
||
|
||
// ───────────────────── Runtime 上的对外入口 ─────────────────────
|
||
|
||
// EventPolicy 返回当前事件策略(拷贝,调用方可安全读取)。
|
||
func (r *Runtime) EventPolicy() EventPolicy {
|
||
if r == nil {
|
||
return DefaultEventPolicy()
|
||
}
|
||
return r.eventPolicy
|
||
}
|
||
|
||
// SetEventPolicy 替换事件策略(配置重载时调用)。
|
||
func (r *Runtime) SetEventPolicy(p EventPolicy) {
|
||
if r == nil {
|
||
return
|
||
}
|
||
r.eventPolicy = p
|
||
}
|
||
|
||
// CheckEventPublish / CheckEventSubscribe / CheckEventPayloadSize 是内核侧
|
||
// (internal/sdk 的事件适配层)唯一该调的入口。
|
||
//
|
||
// nil Runtime 时退回默认策略 —— **失败方向是收紧**(默认表就是最小集合),
|
||
// 不是放行。
|
||
func (r *Runtime) CheckEventPublish(plugin, eventType string) Decision {
|
||
return r.EventPolicy().CheckEventPublish(plugin, eventType)
|
||
}
|
||
|
||
func (r *Runtime) CheckEventSubscribe(plugin, eventType string) Decision {
|
||
return r.EventPolicy().CheckEventSubscribe(plugin, eventType)
|
||
}
|
||
|
||
func (r *Runtime) CheckEventPayloadSize(keys int) Decision {
|
||
return r.EventPolicy().CheckEventPayloadSize(keys)
|
||
}
|