mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 用户裁定
> secutiry 的实现也是错误的,所有共享数据统一挂载一个 kernel 提供的
> 挂载点上暴露。
> store也应该调用security的加密能力!
## 缺陷
security 包原有**三份**独立包级单例,各自被 InitXxx/SetXxx 装入、
各自有访问器,三份都绕开了内核统一挂载点 kstate.State:
global *Store 变量库(凭据别名) Global()
runtime *Runtime 安全防火墙 RuntimeGlobal()
globalCodec *Codec 配置落盘加密 ConfigCodec()
## 为什么单例是错的(不是"多一个全局变量")
本 agent 的 a.security 与 security.Global() 是**两个独立变量**。
正常装配下它们恰好指向同一份数据,但"恰好"不是保证:
· 测试替身:测试装单例、被测代码用 agent 字段 ⇒ 测的与跑的不是同一份
· 多 Runtime:两个内核实例/热重载时单例只有一份,另一方被覆盖
· 装配顺序:单例装晚了,先注册的 provider 就没被装饰
第三条**曾真实存在**:ProviderManager.Register 在注册时读单例套装饰器,
而 llm 模块原本不声明 FacSecurity 依赖 —— 顺序正确纯属巧合。
改动过程中判据如实报红 640 次泄漏。
## 本次做的(T3.1 + T3.3)
### 删 global *Store → 注入
变量库的唯一来源是 security 模块产出的 Runtime,经注入面分发:
AgentConfig.Security → a.security.Store() → a.secretStore()
ProviderManager.SetSecretStore
plugin.Registry.SetSecretStore → sdk.NewSecretsAPI(store)
core 的 4 处 security.Global()(output/toolcall/tooldefs/secret_tools)
改为 a.secretStore();context 与 stageHost 改为注入字段;
provider 装饰器改为读注入的 Store。
### 删 runtime *Runtime → 注入
plugin.Registry.SetSecurityRuntime → sdk.NewSecurityAPI(name, rt)
→ sdk.newEventsAPI(name, bus, rt)
Prepare 的幂等缓存改为 kernelmod/security 私有变量 ——
装配器私有,不再是任何包都能取的公开访问器。
### 顺带删死代码
security.FillToolArgs / FillPayload 生产调用者为 0(StageHost 已自己
用注入的 Store 做同一件事),连同只测它们的判据一起删除。
### llm 模块补 FacSecurity 依赖
这是**真实存在**的依赖:注册 provider 时要套出向装饰器,而装饰器需要
变量库。此前靠"单例先装了"掩盖了顺序错误。
## 判据(均经变异验证)
TestNoPackageLevelSingletons 源码扫描;加回 var global ⇒ 红(实测)
TestMustStoreDoesNotRegisterGlobally 两次 MustStore 必须得到不同实例
TestStoreEncryptsWithOwnKey 落盘无明文 + 重载可解
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + 3 包测试全绿
## 未做(见 kernelplan.md T3.2)
globalCodec 收敛 + Store 复用 Codec
还牵涉 config 的 6 处调用点与**存量密文惰性迁移**,单独一步做。
TestNoPackageLevelSingletons 的禁表暂不含 globalCodec ——
半成品状态长期红的判据会被人习惯性忽略,那时它就等于不存在。
188 lines
6.9 KiB
Go
188 lines
6.9 KiB
Go
package core
|
||
|
||
// 持久化侧路的明文拦截(「记忆防火墙」)。
|
||
//
|
||
// ## 为什么必须单独拦(而不是只靠 provider 装饰器)
|
||
//
|
||
// 出向拦截(`api/provider.go` 的装饰器)只改 `f.Msgs` —— 那是**要发出去的
|
||
// 那一份**。但内核把同一批内容**另存副本**送往持久化存储,那些副本
|
||
// 不经过 provider:
|
||
//
|
||
// ContextEvent.Input ← f.CleanInput (用户输入原文)
|
||
// ContextEvent.Response ← f.Response (模型回复)
|
||
// ContextEvent.ToolResults ← f.ToolResults (工具输出)
|
||
// emitMemoryCandidate.payload.tool_results ← 同上,走 EmitOutput 不经 context
|
||
//
|
||
// 实测确认(变异实验 C6-M1 / C6-M2 / 输入侧探针):
|
||
// 只拦 `f.Msgs` 时,明文会完整地写进 `context.json` **与长期记忆**,
|
||
// 而后者跨会话保留、每轮被召回 —— 泄漏面比"重放一次"大得多。
|
||
//
|
||
// ## 为什么放在 Append/InsertByTimestamp 而不是各调用点
|
||
//
|
||
// 写 context 的地方有 5 处(task.go 3 处、toolcall.go 1 处,以及插入变体)。
|
||
// 逐个补必然漏 —— 本文件的前身就是这么漏掉 `f.CleanInput` 的。
|
||
// `Append` / `InsertByTimestamp` 是全部写入的唯一咽喉,拦这里一处即全覆盖。
|
||
//
|
||
// `emitMemoryCandidate` 不经 context(走 `EmitOutput`),所以单独处理。
|
||
|
||
import (
|
||
"log"
|
||
|
||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||
)
|
||
|
||
// scrubEventSecrets 就地清洗一条待持久化事件里的明文。
|
||
//
|
||
// 失败策略:Store 未装配(未启用安全模块)时**原样返回**,零成本。
|
||
//
|
||
// ★ 2026-11-04:从包级自由函数改为 Context 方法,变量库改为注入。
|
||
// 原因见 RelevanceContext.secretStore 的注释(包级单例绕开统一挂载点)。
|
||
func (c *RelevanceContext) scrubEventSecrets(evt *ContextEvent) {
|
||
if evt == nil {
|
||
return
|
||
}
|
||
s := c.secretStore
|
||
if s == nil {
|
||
return
|
||
}
|
||
if out, evts := s.Replace(evt.Input, "ctx_input", security.Patterns); len(evts) > 0 {
|
||
evt.Input = out
|
||
logEvtHits("上下文输入", evts)
|
||
}
|
||
if out, evts := s.Replace(evt.Response, "ctx_response", security.Patterns); len(evts) > 0 {
|
||
evt.Response = out
|
||
logEvtHits("上下文回复", evts)
|
||
}
|
||
for i := range evt.ToolResults {
|
||
if evt.ToolResults[i].Output == "" {
|
||
continue
|
||
}
|
||
if out, evts := s.Replace(evt.ToolResults[i].Output,
|
||
"ctx_tool:"+evt.ToolResults[i].Name, security.Patterns); len(evts) > 0 {
|
||
evt.ToolResults[i].Output = out
|
||
logEvtHits("上下文工具结果 "+evt.ToolResults[i].Name, evts)
|
||
}
|
||
}
|
||
// 工具调用参数:由模型给出,正常是别名,但插件工具可能被直接传明文。
|
||
//
|
||
// ★ 2026-10-29 补:ToolCalls 是新增的结构化字段,它不在 Input/Response/
|
||
// ToolResults 那条文本闸的覆盖范围内 —— 不加这段就会**绕过整层脱敏**
|
||
// 直接落盘。这正是「新增字段必须显式过闸」的典型例子。
|
||
for i := range evt.ToolCalls {
|
||
tc := &evt.ToolCalls[i]
|
||
for k, v := range tc.Arguments {
|
||
str, ok := v.(string)
|
||
if !ok || str == "" {
|
||
continue
|
||
}
|
||
if out, evts := s.Replace(str, "ctx_toolargs:"+tc.Name+"."+k, security.Patterns); len(evts) > 0 {
|
||
tc.Arguments[k] = out
|
||
logEvtHits("上下文工具参数 "+tc.Name+"."+k, evts)
|
||
}
|
||
}
|
||
if tc.RawArguments != "" {
|
||
if out, evts := s.Replace(tc.RawArguments, "ctx_toolargs_raw:"+tc.Name, security.Patterns); len(evts) > 0 {
|
||
tc.RawArguments = out
|
||
logEvtHits("上下文工具原始参数 "+tc.Name, evts)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 一等记忆块:**文本**块同样可能带凭据(截图描述、文档正文、转写稿)。
|
||
// 媒体块(image/video/audio 的 Data)不处理 —— 那是二进制负载,
|
||
// 正则扫它既无意义也会损坏数据。
|
||
for i := range evt.Blocks {
|
||
if evt.Blocks[i].Text == "" {
|
||
continue
|
||
}
|
||
if out, evts := s.Replace(evt.Blocks[i].Text, "ctx_block", security.Patterns); len(evts) > 0 {
|
||
evt.Blocks[i].Text = out
|
||
logEvtHits("上下文记忆块", evts)
|
||
}
|
||
}
|
||
}
|
||
|
||
// scrubMemoryCandidate 清洗记忆候选载荷(不经 context 的那条侧路)。
|
||
//
|
||
// 就地改写 map:调用方传进来的是一次性的快照,改它没有副作用。
|
||
// ★ 2026-11-04:从包级自由函数改为 Agent 方法(变量库走 a.secretStore())。
|
||
func (a *Agent) scrubMemoryCandidate(payload map[string]interface{}) {
|
||
if payload == nil {
|
||
return
|
||
}
|
||
s := a.secretStore()
|
||
if s == nil {
|
||
return
|
||
}
|
||
for _, key := range []string{"input", "response"} {
|
||
str, ok := payload[key].(string)
|
||
if !ok || str == "" {
|
||
continue
|
||
}
|
||
if out, evts := s.Replace(str, "memory:"+key, security.Patterns); len(evts) > 0 {
|
||
payload[key] = out
|
||
logEvtHits("记忆候选 "+key, evts)
|
||
}
|
||
}
|
||
switch trs := payload["tool_results"].(type) {
|
||
case []ToolResultItem:
|
||
for i := range trs {
|
||
if trs[i].Output == "" {
|
||
continue
|
||
}
|
||
if out, evts := s.Replace(trs[i].Output,
|
||
"memory:tool:"+trs[i].Name, security.Patterns); len(evts) > 0 {
|
||
trs[i].Output = out
|
||
logEvtHits("记忆候选工具结果 "+trs[i].Name, evts)
|
||
}
|
||
}
|
||
payload["tool_results"] = trs
|
||
case []interface{}:
|
||
// 有的调用方传的是未类型化的切片(如经 JSON 中转)。
|
||
for i := range trs {
|
||
m, ok := trs[i].(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
out, ok := m["output"].(string)
|
||
if !ok || out == "" {
|
||
continue
|
||
}
|
||
if scrubbed, evts := s.Replace(out, "memory:tool", security.Patterns); len(evts) > 0 {
|
||
m["output"] = scrubbed
|
||
logEvtHits("记忆候选工具结果", evts)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func logEvtHits(where string, evts []security.Evt) {
|
||
for _, e := range evts {
|
||
log.Printf("[security] %s blocked %s (pattern %s, %d bytes, merged=%v)",
|
||
where, e.Alias, e.Kind, e.Len, e.Merged)
|
||
}
|
||
}
|
||
|
||
// ---------------- 两处拦截的关系(重要,别删任一处) ----------------
|
||
//
|
||
// 实测确认:`Append` 里的 `scrubEventSecrets` 会**连带改掉** `f.ToolResults` ——
|
||
// 因为 `turnEvt.ToolResults = f.ToolResults` 是值切片,共享底层数组,
|
||
// 而 scrub 对 `evt.ToolResults[i].Output` 是就地赋值。
|
||
//
|
||
// 于是 `emitMemoryCandidate` 收到时往往已是别名 ⇒ 单独去掉
|
||
// `scrubMemoryCandidate` 的变异实验**不会报红**(已验证)。
|
||
//
|
||
// ## 那为什么还要保留 scrubMemoryCandidate
|
||
//
|
||
// 因为「隐式依赖别名」是**脆弱**的:只要有人
|
||
// · 把 `turnEvt.ToolResults` 改成深拷贝(很自然的"防御性拷贝"改动),
|
||
// · 或调整 Append 与 emitMemoryCandidate 的先后顺序,
|
||
// · 或新增一条不经过 Append 的写入路径,
|
||
// 记忆侧就会静默恢复泄漏,而没有任何判据会报红。
|
||
//
|
||
// `scrubMemoryCandidate` 是**显式契约**:无论上游怎么排版,记忆候选本身
|
||
// 必须自己清洗。幂等(已是别名的不会再命中),代价只是一次正则扫描。
|
||
//
|
||
// 同理 `Append` 与 `InsertByTimestamp` 两处都要调 `scrubEventSecrets`:
|
||
// 它们是两个独立入口,不能只保一处。
|