mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 起因(生产故障)
A2A 分诊助手(驻留子)把 terminal_create 当一次性命令执行器:
terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && ls -d */ | head -60 && …')
→ read /dev/ptmx: input/output error
→ 再开 term_7、term_8、term_9 …
链路拆开四层:
① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run
② 需要 cmd-tools skill 说明"先 plugin_start cmd"
③ 但 skill 索引没进那个 agent 的提示词
④ 因为 skillIndex 只是根 agent 的字段、由 bootstrap 手工接线
④ 才是总根。而它之所以必须手工接线:agent/core 读不到内核的共享数据。
## 环的精确形状(实测,非推测)
kernelboot 因为持有 KernelState 而依赖 19 个业务包:
kernelboot ──▶ agent/core ──▶ …
──▶ plugin
──▶ security
──▶ memory / knowledge / text / coldcontext / …
而 kernelboot.go / scheduler.go / funcmodule.go / syncbus.go / lifecycle.go
**一行业务代码都不需要** —— 依赖全在 kernelstate.go 这一个数据文件里。
后果是四个底层包各自造通道:
core → Agent 字段(每种 agent 构造点都要记得传)
plugin → 12 个 SetXxx
sdk → SDKConfig 传参
security → 包级单例 Global() / RuntimeGlobal() / ConfigCodec()
四条通道是同一个根因的四个表现。
## 本次做的事
### 1. 抽出 internal/kstate(断环)
kernelstate.go / syncbus.go / lifecycle.go 搬出 kernelboot:
internal/kstate 纯数据 + 唯一挂载点(叶子,谁都能 import)
internal/kernelboot 装配引擎,零业务包
挂载点 API:Mount / Get(nil 安全)/ MountForTest(返回 restore,并行测试隔离)。
验证:core 现在能 import kstate(此前 import cycle not allowed)。
### 2. StageHost / Agent 字段存 any
它们是 core 自己的类型(core 是环的一极,这是真环)。按用户裁定的方向:
> 是其他包在kstate挂共享内容啊
存 any,消费方断言回具体类型 —— 与本仓既有的 PluginCatalog /
PluginScheduler / UpdateCapabilities 同型。
★ 其余字段保持**具体类型**,没有一律改 any。判据是"它会不会反向依赖
agent/core":实测 plugin / security / sdk 都不依赖 core,所以它们作为
具体类型不构成环,而类型化字段让"拼错/取错类型"是编译错误(本结构体的
设计原则)。
### 3. 插件声明式系统提示词段
SDK 新增 SystemPromptAPI(见 submodule 提交)。内核侧:
kstate.State.PromptSections *promptsec.Registry ← 唯一事实来源
Agent.renderSystemPrompt() 每轮重建时读它
skillmgr 从"实现 SkillIndexProvider 让内核来问"改为"声明自己的段":
DeclarePromptSection{Section:"skillmgr.skills", Order:100, Volatile:true}
并挂到全部变更点(启动全扫后、移交事件后、load/unload/create/install/
enable/disable 后、Stop 时撤销)。
### 4. 内核自身的提示词段也走同一管理器
按用户裁定「把现在内核所有 systemprompt 的相关注入都接入到这个统一的
systemprompt 管理器上」,把 buildSystemPrompt 里硬拼的十几块
(人格卡/任务提示词/记忆说明/输出规则/工具目录…)改为声明式:
kernelSections() 当轮计算 → 与插件段按 Order 合并渲染。
顺序由 Order 常量决定,改结构变成调常量而不是挪代码块。
## 判据(均经变异验证)
TestCoreCanImportKstate 真编译探针;环重现则红(实测)
TestKstateDoesNotDependOnCore 静态版,指向具体字段
TestKstateIsLeaf kstate 不得反依赖上层
TestRTChildSeesPromptSections 子 agent 能看到插件段
TestResidentSeesPromptSections 生产实际中招的那一类;变异后红(实测)
TestRootAgentSeesPromptSections 确认没改坏根路径
TestNoPromptSectionsMount 未装配时不 panic、不凭空出现段
TestDeclareTwiceRendersOnce ★ 回应"会不会多份":声明 5 次渲染 1 份
TestSectionRemovalTakesEffect Stop 后段消失
TestRuntimeUpdateVisibleToExistingAgent 同一实例下一轮看到新内容
TestSectionOrderIsRespected Order 生效且稳定
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l internal/ cmd/ pkg/ 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + 4 包测试全绿
## 未做(见 kernelplan.md)
T3 security 三单例收敛(含 Store 复用 Codec 的惰性迁移)
T5 plugin Setter 收敛
T6 防腐判据(部分已随本次落地)
203 lines
8.3 KiB
Go
203 lines
8.3 KiB
Go
// Package security 是 security 模块:**安全防火墙**(Secret Firewall + 边界闸门)。
|
||
//
|
||
// ## 它是什么(用户裁定 2026-10-26)
|
||
//
|
||
// > 安全模块不是独立的模块吗?core 发出的请求和 device 返回的请求都会
|
||
// > 经过安全模块审查,通过才能进行下一步,否则会被拦截 —— 相当于安全模块
|
||
// > 就是横在其他所有模块与 core 之间的一堵墙。
|
||
//
|
||
// 所以本模块不是"某项能力",而是**数据面边界**:
|
||
//
|
||
// Core ──出向审查──▶ 外部(device / plugin / channel)
|
||
// Core ◀─入向审查── 外部
|
||
//
|
||
// 落地形态是**显式代理**(用户确认的说法只是方便解耦):把 KernelState 上
|
||
// 的公共挂载点换成本模块产出的安全对象,业务模块继续认为自己直接对端交互。
|
||
//
|
||
// ## 依赖与分层位置
|
||
//
|
||
// config → security → memory / knowledge / agent / device / pluginhost
|
||
//
|
||
// 只依赖 config:它需要 `core.security.enabled` 与 `ks.DataDir`。
|
||
// ★ 刻意**不依赖 agent**:agent 之后要用 `ks.Security`,反之成环。
|
||
//
|
||
// ## 与 Group 0 的关系(重要)
|
||
//
|
||
// 密钥与变量库必须在**任何读 core.llm.* 的操作之前**就绪(config 模块的
|
||
// 注释记录了 401 invalid api key 的实测故障)。而读 `core.llm.*` 发生在
|
||
// `cfgReg.ToConfig()`(Group 0)里,**早于**本调度器。
|
||
//
|
||
// ⇒ 本模块把密钥与 Store 的装配放进 Init,但**同时**提供
|
||
// `Prepare()` 供 Group 0 在 ToConfig 之前调用(幂等)。
|
||
// 这正是原先 cmd/homed 里那段"必须前移"的代码的归宿。
|
||
package security
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"path/filepath"
|
||
|
||
"github.com/JianFeeeee/HologramAgent/internal/config"
|
||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
|
||
"github.com/JianFeeeee/HologramAgent/internal/kstate"
|
||
sec "github.com/JianFeeeee/HologramAgent/internal/security"
|
||
)
|
||
|
||
// Module 提供安全防火墙设施。
|
||
type Module struct{}
|
||
|
||
func (Module) Name() string { return "security" }
|
||
func (Module) Requires() []string { return []string{kernelmod.FacConfig} }
|
||
func (Module) Provides() []string { return []string{kernelmod.FacSecurity} }
|
||
|
||
func (Module) Init(ks *kstate.State) error {
|
||
if ks.DataDir == "" {
|
||
return fmt.Errorf("security module: ks.DataDir is empty - Group 0 must set the data dir first")
|
||
}
|
||
if ks.CfgReg == nil {
|
||
return fmt.Errorf("security module: ks.CfgReg is nil (requires the config facility)")
|
||
}
|
||
rt, err := Prepare(ks.DataDir, ks.CfgReg)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ks.Security = rt
|
||
|
||
// ★ 把安全策略注入**总线本体**(用户 2026-10-28 指正:「eventbus 上所有
|
||
// 事件都要接受安全模块的监控」)。
|
||
//
|
||
// 为何必须在总线上而不是只在 internal/sdk/events.go:
|
||
// 后者只盖住了 s.Events() 那**一条**路径,而插件还有 s.Publish /
|
||
// s.Subscribe / s.EventBus() 三条能拿裸总线的路。不装在这里,
|
||
// “安全监控”就只对合规者生效。
|
||
//
|
||
// ★ rt 为 nil 或未 enable 时**仍然装**:enable=false 关掉的是凭据替换,
|
||
// “插件不许发 raw_input”这类授权与密钥无关(见 EventPolicy 头注释)。
|
||
// 不装就等于默认放行全部事件,方向错误。
|
||
if ks.EventBus != nil {
|
||
if g := sec.AsEventGuard(rt); g != nil {
|
||
ks.EventBus.SetGuard(g)
|
||
}
|
||
}
|
||
|
||
if ks.EventBus != nil && rt.Enabled() {
|
||
ks.EventBus.Publish(secReadyEvent(rt))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func init() { kernelmod.Register(Module{}) }
|
||
|
||
// Prepare 装配安全运行时(**幂等**)。
|
||
//
|
||
// 可在 Group 0(`cfgReg.ToConfig()` 之前)先调一次,也可以由模块 Init 调。
|
||
// 第二次调用直接返回已装好的实例 —— 因为 `InitGlobal` 会重读并重建变量库,
|
||
// 而那会在 ToConfig 已经解出密文之后把 Store 换掉,徒增风险。
|
||
//
|
||
// 返回的 Runtime 里带着 Store 与 Gate(边界闸门)。
|
||
func Prepare(dataDir string, cfgReg *config.ConfigRegistry) (*sec.Runtime, error) {
|
||
if rt := sec.RuntimeGlobal(); rt != nil {
|
||
return rt, nil
|
||
}
|
||
if cfgReg == nil {
|
||
return nil, fmt.Errorf("security: cfgReg is nil")
|
||
}
|
||
if !cfgReg.GetBool("core.security.enabled", true) {
|
||
// 显式关闭:装一个空 Runtime(所有闸门零成本透传),
|
||
// 而不是留 nil 让调用点各自判断。
|
||
rt := sec.NewDisabledRuntime()
|
||
sec.SetRuntimeGlobal(rt)
|
||
log.Printf("[security] module disabled by config (core.security.enabled=false)")
|
||
return rt, nil
|
||
}
|
||
|
||
// ★ 先收敛敏感文件权限,再读密钥。
|
||
//
|
||
// 顺序有意为之:内嵌主密钥把密钥放进了 0755 的二进制里,而 config.db
|
||
// 出厂是 0644 ——「能读二进制就能解密数据库」。收紧到 0600/0750 是本机
|
||
// 缓解手段,必须在服务开始读写之前生效。失败不致命(权限问题不该让服务
|
||
// 不可用),但要逐条响亮记录。
|
||
if hr := sec.HardenDataDir(dataDir); len(hr.Changed) > 0 {
|
||
log.Printf("[security] hardened %d file permissions (necessary mitigation for embedded-key scenario): %v",
|
||
len(hr.Changed), hr.Changed)
|
||
}
|
||
|
||
key, keySrc, err := sec.LoadOrCreateKey(dataDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("security key init failed (refusing to start in degraded mode): %w", err)
|
||
}
|
||
oldKeys, err := sec.LoadKeyring(dataDir, key)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("security keyring load failed (refusing to silently pick wrong key): %w", err)
|
||
}
|
||
// ★ 内嵌钥上台时,磁盘钥必须继续参与**解密**。
|
||
// 否则用磁盘钥加密过的老机器升级后会全部解不开 ⇒ 服务起不来;
|
||
// 用户此时若"重建库",凭据一次性全丢 —— 恰好与内嵌的初衷相反。
|
||
if extra := sec.AdditionalDecryptKeys(dataDir, key); len(extra) > 0 {
|
||
oldKeys = append(oldKeys, extra...)
|
||
}
|
||
if len(oldKeys) > 0 {
|
||
log.Printf("[security] loaded %d historical/extra key(s) (smooth rotation + embedded migration)", len(oldKeys))
|
||
}
|
||
|
||
store, err := sec.InitGlobal(sec.Options{
|
||
Path: filepath.Join(dataDir, "security", "variables.json"),
|
||
KeyFn: func() ([]byte, error) { return key, nil },
|
||
OldKeys: oldKeys,
|
||
Enabled: true,
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("security variable store load failed (will not silently rebuild): %w", err)
|
||
}
|
||
|
||
// 配置落盘用的子密钥(域隔离:与别名密钥不共用)。
|
||
if _, err := sec.InitConfigCodecWithOldKeys(key, oldKeys); err != nil {
|
||
return nil, fmt.Errorf("config-encryption subkey derivation failed: %w", err)
|
||
}
|
||
// 把库里仍是明文的敏感项就地加密(幂等;升级路径必须跑)。
|
||
if n, merr := cfgReg.EncryptExistingSecrets(); merr != nil {
|
||
log.Printf("[security] existing sensitive config encryption incomplete (some entries still plaintext): %v", merr)
|
||
} else if n > 0 {
|
||
log.Printf("[security] encrypted %d existing sensitive config entries", n)
|
||
}
|
||
|
||
// E-2:备份跨机器恢复的诊断(症状极具误导性,一次性说清事实)。
|
||
if okN, badN, aerr := cfgReg.AuditSecrets(); aerr == nil && badN > 0 {
|
||
log.Printf("[security] WARNING: %d ciphertext entries cannot be decrypted with current master key (%d decryptable) — "+
|
||
"likely a config.db restored from another machine without its master key", badN, okN)
|
||
}
|
||
|
||
rt := sec.NewRuntime(store)
|
||
sec.SetRuntimeGlobal(rt)
|
||
|
||
// ★ 全局策略基线(部署方兵底,用户 2026-10-29 裁定选项 B)。
|
||
//
|
||
// 它必须在这里装(而不是等模块 Init):`Prepare` 可能在 Group 0 被
|
||
// 提前调一次,而工具可能在那之后任意时刻被执行 —— 策略若晚一步生效,
|
||
// 就存在一个“工具已在跑、但黑名单还没上”的窗口。
|
||
//
|
||
// 注:配置里全空/全 false 时得到零值 Policy(不限制),
|
||
// 与加配置前的行为逐字一致。
|
||
ApplyPolicyFromConfig(rt, cfgReg)
|
||
|
||
log.Printf("[security] module enabled (key source %s, %d variables, config-at-rest encryption on)",
|
||
keySrc, store.Count())
|
||
return rt, nil
|
||
}
|
||
|
||
// secReadyEvent 构造"安全防火墙已装配"事件。
|
||
//
|
||
// 为什么带 enabled 与 variables:状态面要能回答"审查是否真的在生效",
|
||
// 而"配置开了但装配失败"与"配置显式关闭"是两件不同的事。
|
||
func secReadyEvent(rt *sec.Runtime) *events.Event {
|
||
return &events.Event{
|
||
Type: events.EventSecurityReady,
|
||
Source: "kernelmod/security",
|
||
Payload: map[string]interface{}{
|
||
"enabled": rt.Enabled(),
|
||
"variables": rt.Count(),
|
||
},
|
||
}
|
||
}
|