mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +00:00
refactor(security): 删除三份包级单例,共享数据统一挂 kstate
## 用户裁定
> 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 ——
半成品状态长期红的判据会被人习惯性忽略,那时它就等于不存在。
This commit is contained in:
@ -584,6 +584,22 @@ func New(cfg AgentConfig) *Agent {
|
||||
lastInput: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// 变量库注入:context 的落盘前脱敏要用**本 agent** 的变量库,
|
||||
// 而不是进程级单例(见 RelevanceContext.secretStore 的注释)。
|
||||
if a.security != nil {
|
||||
if a.context != nil {
|
||||
a.context.SetSecretStore(a.security.Store())
|
||||
}
|
||||
if a.stageHost != nil {
|
||||
a.stageHost.SetSecretStore(a.security.Store())
|
||||
}
|
||||
// ProviderManager 是**全进程共享**的一份(主/子/驻留子同源),
|
||||
// 出向装饰要在它 Register 时生效,所以注入也必须到它身上。
|
||||
if a.providerManager != nil {
|
||||
a.providerManager.SetSecretStore(a.security.Store())
|
||||
}
|
||||
}
|
||||
|
||||
// 输入路由:inputch 是可分配资源,划给某个 agent 后输入**只**流向那个 agent
|
||||
// (设计 §4.1「路由发生在进内核之前」)。io 层不认识 agent,所以在这里把路由器
|
||||
// 注入进去:插件注入输入时先问它,被别的 agent 接管就不再进本内核队列。
|
||||
|
||||
@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
@ -157,7 +158,18 @@ type RelevanceContext struct {
|
||||
saveTimer *time.Timer
|
||||
dirty bool
|
||||
toolDefLookup func(name string) *sdk.ToolDef
|
||||
channelDefLookup func(name string) (sdk.ChannelDef, bool)
|
||||
|
||||
// secretStore 是**变量库**(凭据别名),用于事件落进 L1 前的脱敏。
|
||||
//
|
||||
// ★ 为什么是注入字段而不是读 security.Global()(2026-11-04):
|
||||
// 包级单例绕开了统一挂载点 —— 本 Context 属于哪个 agent 就该用哪个
|
||||
// agent 的变量库。单例在正常装配下"恰好"是同一份,但测试替身、
|
||||
// 多 Runtime、热重载任一路径都可能分离,症状是"策略生效但脱敏不生效"
|
||||
// (或反之),极难定位。
|
||||
//
|
||||
// nil 合法:未启用安全模块时脱敏零成本跳过。
|
||||
secretStore *security.Store
|
||||
channelDefLookup func(name string) (sdk.ChannelDef, bool)
|
||||
|
||||
// protectedCount 是裁剪时**无条件保留**的最近事件条数。
|
||||
//
|
||||
@ -718,6 +730,16 @@ func (c *RelevanceContext) ProtectedCount() int {
|
||||
return c.protectedCount
|
||||
}
|
||||
|
||||
// SetSecretStore 注入变量库(装配期由 Agent 调用)。
|
||||
func (c *RelevanceContext) SetSecretStore(s *security.Store) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.secretStore = s
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext {
|
||||
rc := &RelevanceContext{
|
||||
embedder: embedder,
|
||||
@ -968,7 +990,7 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
scrubEventSecrets(&evt)
|
||||
c.scrubEventSecrets(&evt)
|
||||
c.computeVector(&evt)
|
||||
c.events = append(c.events, &evt)
|
||||
|
||||
@ -999,7 +1021,7 @@ func (c *RelevanceContext) InsertByTimestamp(evt ContextEvent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
scrubEventSecrets(&evt) // 同 Append:咽喉处拦截
|
||||
c.scrubEventSecrets(&evt) // 同 Append:咽喉处拦截
|
||||
c.computeVector(&evt)
|
||||
c.claimPendingToolResultsLocked(&evt) // 同 Append:认领待登记的落盘记录
|
||||
|
||||
|
||||
@ -624,7 +624,7 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults
|
||||
}
|
||||
// ★ 记忆候选是**不经 context** 的另一条持久化侧路(走 EmitOutput)。
|
||||
// 不在这里拦,明文会直接进长期记忆 —— 而记忆跨会话保留、每轮被召回。
|
||||
scrubMemoryCandidate(payload)
|
||||
a.scrubMemoryCandidate(payload)
|
||||
a.io.EmitOutput("memory", "memory_candidate", payload)
|
||||
}
|
||||
|
||||
|
||||
@ -17,8 +17,9 @@ import (
|
||||
)
|
||||
|
||||
func TestPremise_EncShellCommandPrefilledByKernel(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
rt := newScrubRuntime(t)
|
||||
store := rt.Store()
|
||||
a, _, _ := newRootWithSecurity(t, &countingProvider{}, rt)
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
alias, err := store.Submit(tok, "测")
|
||||
|
||||
@ -30,8 +30,9 @@ import (
|
||||
//
|
||||
// 后者完全走内核机制,插件无需自带加密库与别名表。
|
||||
func TestKernelFillCoversBuiltinToolsToo(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
rt := newScrubRuntime(t)
|
||||
store := rt.Store()
|
||||
a, _, _ := newRootWithSecurity(t, &countingProvider{}, rt)
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
alias, err := store.Submit(tok, "测试")
|
||||
@ -81,8 +82,9 @@ func TestKernelFillCoversBuiltinToolsToo(t *testing.T) {
|
||||
//
|
||||
// 但它的 argv 保护(脚本走 stdin)与输出脱敏**仍然独有**,不可删。
|
||||
func TestKernelFillHappensBeforePluginHandler(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
rt := newScrubRuntime(t)
|
||||
store := rt.Store()
|
||||
a, _, _ := newRootWithSecurity(t, &countingProvider{}, rt)
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
alias, err := store.Submit(tok, "测试")
|
||||
|
||||
@ -42,19 +42,21 @@ func fwToolDef(name string) sdk.ToolDef {
|
||||
}
|
||||
}
|
||||
|
||||
func setupFirewall(t *testing.T) (*security.Store, string) {
|
||||
func setupFirewall(t *testing.T) (*security.Runtime, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store, err := security.InitGlobal(security.Options{
|
||||
// ★ 2026-11-04:不再用包级单例。Runtime 是唯一来源,
|
||||
// 由装配路径注入 Agent(AgentConfig.Security → a.security)。
|
||||
s := security.New(security.Options{
|
||||
Path: filepath.Join(dir, "vars.json"),
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
if err := s.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
return store, dir
|
||||
// 返回 Runtime 而不是 Store:现在唯一注入面是 AgentConfig.Security。
|
||||
return security.NewRuntime(s), dir
|
||||
}
|
||||
|
||||
// ★★ 判据:插件工具收到的参数是**明文**(回填生效)。
|
||||
@ -62,8 +64,9 @@ func setupFirewall(t *testing.T) (*security.Store, string) {
|
||||
// 这是整个设计的核心目的:模型手上只有别名,插件拿到真凭据,
|
||||
// 而**插件对替换完全无感知**。
|
||||
func TestFirewall_ToolArgsFilledBeforePlugin(t *testing.T) {
|
||||
store, _ := setupFirewall(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
rt, _ := setupFirewall(t)
|
||||
store := rt.Store()
|
||||
a, _, _ := newRootWithSecurity(t, &countingProvider{}, rt)
|
||||
|
||||
// 插件工具:记录它实际收到的参数
|
||||
var seenToken string
|
||||
@ -101,8 +104,9 @@ func TestFirewall_ToolArgsFilledBeforePlugin(t *testing.T) {
|
||||
// 若插错(比如 stage 之前),插件会看到明文;
|
||||
// 若压根没插,设备会收到字面的 "@{sec_xxx}"。
|
||||
func TestFirewall_DevicePayloadFilledBeforeExecute(t *testing.T) {
|
||||
store, _ := setupFirewall(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
rt, _ := setupFirewall(t)
|
||||
store := rt.Store()
|
||||
a, _, _ := newRootWithSecurity(t, &countingProvider{}, rt)
|
||||
|
||||
var delivered string
|
||||
const outCh = "plugintest_secch"
|
||||
@ -143,9 +147,11 @@ func TestFirewall_DevicePayloadFilledBeforeExecute(t *testing.T) {
|
||||
|
||||
// ★ 判据:出向拦截真的发生在真实 provider 调用上(用真实 ProviderManager)。
|
||||
func TestFirewall_OutboundScrubbedOnRealProviderPath(t *testing.T) {
|
||||
setupFirewall(t)
|
||||
rt, _ := setupFirewall(t)
|
||||
var captured string
|
||||
mgr := agentAPI.NewProviderManager()
|
||||
// ★ 注入必须先于 Register:装饰器在 Register 时套上。
|
||||
mgr.SetSecretStore(rt.Store())
|
||||
mgr.Register("probe", &fwCaptureProvider{name: "probe", capture: &captured})
|
||||
|
||||
_, err := mgr.Default().Chat(context.Background(), &agentAPI.CompletionRequest{
|
||||
@ -167,7 +173,8 @@ func TestFirewall_OutboundScrubbedOnRealProviderPath(t *testing.T) {
|
||||
|
||||
// ★ 判据:磁盘上**没有明文**(加密落盘),且权限 0600。
|
||||
func TestFirewall_NoPlaintextOnDisk(t *testing.T) {
|
||||
store, dir := setupFirewall(t)
|
||||
rt, dir := setupFirewall(t)
|
||||
store := rt.Store()
|
||||
if _, err := store.Submit(firewallToken, "落盘"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -195,7 +202,8 @@ func TestFirewall_NoPlaintextOnDisk(t *testing.T) {
|
||||
|
||||
// ★ 判据:**同明文合并**(同一次对话里多个通道用同一个 secret ⇒ 同一别名)。
|
||||
func TestFirewall_SameSecretMergesAcrossChannels(t *testing.T) {
|
||||
store, _ := setupFirewall(t)
|
||||
rt, _ := setupFirewall(t)
|
||||
store := rt.Store()
|
||||
|
||||
// 两处独立出现同一明文(模拟两个插件都用同一个 token)
|
||||
alias1, _ := store.Submit(firewallToken, "通道A")
|
||||
@ -214,8 +222,9 @@ func TestFirewall_SameSecretMergesAcrossChannels(t *testing.T) {
|
||||
//
|
||||
// 这是完整闭环:拦截 → 别名进上下文 → 模型用别名 → 回填 → 插件拿到明文。
|
||||
func TestFirewall_FullRoundTrip(t *testing.T) {
|
||||
store, _ := setupFirewall(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
rt, _ := setupFirewall(t)
|
||||
store := rt.Store()
|
||||
a, _, _ := newRootWithSecurity(t, &countingProvider{}, rt)
|
||||
|
||||
var pluginSaw string
|
||||
const toolName = "plugintest_roundtrip"
|
||||
|
||||
@ -26,20 +26,12 @@ import (
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
)
|
||||
|
||||
// ★★ 判据:装饰器原地改写 ⇒ 原始消息切片也变成别名。
|
||||
func TestOutboundScrubIsInPlaceByDesign(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := security.InitGlobal(security.Options{
|
||||
Path: dir + "/v.json",
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
// ★ 2026-11-04:变量库改为注入 ProviderManager(不再用包级单例)。
|
||||
store := newScrubRuntime(t).Store()
|
||||
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
@ -48,6 +40,7 @@ func TestOutboundScrubIsInPlaceByDesign(t *testing.T) {
|
||||
|
||||
var captured string
|
||||
mgr := agentAPI.NewProviderManager()
|
||||
mgr.SetSecretStore(store) // 注入必须先于 Register(装饰在 Register 时发生)
|
||||
mgr.Register("p", &fwCaptureProvider{name: "p", capture: &captured})
|
||||
|
||||
if _, err := mgr.Default().Chat(context.Background(),
|
||||
|
||||
@ -37,6 +37,7 @@ func TestPremise_PluginReturnedPlaintextIsScrubbed(t *testing.T) {
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
Security: newScrubRuntime(t),
|
||||
})
|
||||
if err := a.stageHost.RegisterTool("leaky", sdk.ToolDef{
|
||||
Name: "leaky", Description: "故意回吐明文",
|
||||
@ -84,6 +85,7 @@ func TestPremise_PluginReturnedPlaintextNeverReachesLLM(t *testing.T) {
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
Security: newScrubRuntime(t),
|
||||
})
|
||||
if err := a.stageHost.RegisterTool("leaky2", sdk.ToolDef{
|
||||
Name: "leaky2", Description: "故意回吐明文",
|
||||
|
||||
@ -38,11 +38,13 @@ import (
|
||||
//
|
||||
// where 用于日志里标注拦截位置(如 "tool_result:enc_shell"),
|
||||
// 便于事后审计"哪些工具的输出带凭据"。
|
||||
func scrubForLog(text, where string) string {
|
||||
// ★ 2026-11-04:从包级自由函数改为 Agent 方法(变量库走 a.secretStore())。
|
||||
// 原实现读 security.Global() —— 那绕开了统一挂载点,见 RelevanceContext.secretStore。
|
||||
func (a *Agent) scrubForLog(text, where string) string {
|
||||
if text == "" {
|
||||
return text
|
||||
}
|
||||
st := security.Global()
|
||||
st := a.secretStore()
|
||||
if st == nil {
|
||||
return text
|
||||
}
|
||||
|
||||
@ -31,12 +31,14 @@ import (
|
||||
|
||||
// ★★★ 判据:scrubForLog 把明文换成别名。
|
||||
func TestScrubForLogReplacesPlaintext(t *testing.T) {
|
||||
newScrubStore(t)
|
||||
// ★ 2026-11-04:判据改为走**真实注入路径**(Agent.security → secretStore)。
|
||||
// 此前是"装了全局单例,然后调包级函数"——那测的是单例,不是接线。
|
||||
a := withScrubRuntime(New(AgentConfig{ID: "log-scrub"}), newScrubRuntime(t))
|
||||
|
||||
const tok = "sk-" + "abcdefghijklmnopqrstuvwxyz0123"
|
||||
in := `{"output":"HTTP 200 {` + `"key":"` + tok + `"}"}`
|
||||
|
||||
out := scrubForLog(in, "tool_result:enc_shell")
|
||||
out := a.scrubForLog(in, "tool_result:enc_shell")
|
||||
if strings.Contains(out, tok) {
|
||||
t.Fatalf("★ 日志文本里仍有明文 key:%s", out)
|
||||
}
|
||||
@ -47,17 +49,17 @@ func TestScrubForLogReplacesPlaintext(t *testing.T) {
|
||||
|
||||
// ★★★ 判据:普通文本不被改动(不误伤)。
|
||||
func TestScrubForLogLeavesNormalTextAlone(t *testing.T) {
|
||||
newScrubStore(t)
|
||||
a := withScrubRuntime(New(AgentConfig{ID: "log-scrub2"}), newScrubRuntime(t))
|
||||
const normal = "工具 cmd_run 执行成功,输出 42 行,无异常"
|
||||
if got := scrubForLog(normal, "tool_result:cmd_run"); got != normal {
|
||||
if got := a.scrubForLog(normal, "tool_result:cmd_run"); got != normal {
|
||||
t.Fatalf("★ 普通文本被改动了:%q → %q", normal, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:security.Global() 为 nil 时不 panic(安全模块未启用是常态)。
|
||||
// ★★★ 判据:Agent 没有 security 时不 panic(安全模块未启用是常态)。
|
||||
func TestScrubForLogNilStoreIsSafe(t *testing.T) {
|
||||
// 不初始化 store
|
||||
got := scrubForLog("文本 sk-abcdefghijklmnopqrstuvwxyz0123", "x")
|
||||
a := New(AgentConfig{ID: "log-scrub-nil"}) // 不注入 security
|
||||
got := a.scrubForLog("文本 sk-abcdefghijklmnopqrstuvwxyz0123", "x")
|
||||
if got == "" {
|
||||
t.Fatal("nil store 时不该返回空")
|
||||
}
|
||||
|
||||
@ -34,11 +34,14 @@ import (
|
||||
// scrubEventSecrets 就地清洗一条待持久化事件里的明文。
|
||||
//
|
||||
// 失败策略:Store 未装配(未启用安全模块)时**原样返回**,零成本。
|
||||
func scrubEventSecrets(evt *ContextEvent) {
|
||||
//
|
||||
// ★ 2026-11-04:从包级自由函数改为 Context 方法,变量库改为注入。
|
||||
// 原因见 RelevanceContext.secretStore 的注释(包级单例绕开统一挂载点)。
|
||||
func (c *RelevanceContext) scrubEventSecrets(evt *ContextEvent) {
|
||||
if evt == nil {
|
||||
return
|
||||
}
|
||||
s := security.Global()
|
||||
s := c.secretStore
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
@ -102,11 +105,12 @@ func scrubEventSecrets(evt *ContextEvent) {
|
||||
// scrubMemoryCandidate 清洗记忆候选载荷(不经 context 的那条侧路)。
|
||||
//
|
||||
// 就地改写 map:调用方传进来的是一次性的快照,改它没有副作用。
|
||||
func scrubMemoryCandidate(payload map[string]interface{}) {
|
||||
// ★ 2026-11-04:从包级自由函数改为 Agent 方法(变量库走 a.secretStore())。
|
||||
func (a *Agent) scrubMemoryCandidate(payload map[string]interface{}) {
|
||||
if payload == nil {
|
||||
return
|
||||
}
|
||||
s := security.Global()
|
||||
s := a.secretStore()
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@ -8,7 +8,6 @@ import (
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
)
|
||||
|
||||
// executeOutputSendTool 执行一次 output_send__{通道}。
|
||||
@ -313,7 +312,7 @@ func (a *Agent) sendOneOutput(channel string, item outputItem) (string, bool) {
|
||||
// · 放在 stage 之前 ⇒ 插件会看到明文(不可,插件在信任边界外)
|
||||
// · 放在 Execute 之后 ⇒ 设备已收到带别名的载荷(功能坏了)
|
||||
// 所以只能夹在中间:插件看不到,设备拿到真的。
|
||||
if sc := security.Global(); sc != nil {
|
||||
if sc := a.secretStore(); sc != nil {
|
||||
if filled, n, used := sc.Fill(args["payload"].(string)); n > 0 {
|
||||
args["payload"] = filled
|
||||
log.Printf("[security] channel %s payload backfilled %d place(s) (aliases %v)", channel, n, used)
|
||||
|
||||
@ -34,7 +34,8 @@ import (
|
||||
|
||||
// ★★ 判据:**用户输入**里的明文不得进 context(此前完全未被覆盖)。
|
||||
func TestE2E_InputSecretNeverReachesContext(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
rt := newScrubRuntime(t)
|
||||
store := rt.Store()
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
a := New(AgentConfig{
|
||||
@ -43,6 +44,7 @@ func TestE2E_InputSecretNeverReachesContext(t *testing.T) {
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
Security: rt, // 必须与 store 同源
|
||||
})
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
@ -68,7 +70,8 @@ func TestE2E_InputSecretNeverReachesContext(t *testing.T) {
|
||||
// 模型看不到明文(出向已拦),所以它只能在**被喂了明文**时才回吐明文 ——
|
||||
// 这里用脚本 provider 直接造出那个回复,验证回复侧拦截生效。
|
||||
func TestE2E_ResponseSecretNeverReachesContext(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
rt := newScrubRuntime(t)
|
||||
store := rt.Store()
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
a := New(AgentConfig{
|
||||
@ -77,6 +80,7 @@ func TestE2E_ResponseSecretNeverReachesContext(t *testing.T) {
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
Security: rt, // 必须与 store 同源
|
||||
})
|
||||
a.Start()
|
||||
defer a.Stop()
|
||||
@ -96,7 +100,8 @@ func TestE2E_ResponseSecretNeverReachesContext(t *testing.T) {
|
||||
|
||||
// ★ 判据:**记忆块**(一等记忆的文本负载)也要过一遍。
|
||||
func TestScrubEventCoversMemoryBlocks(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
rt := newScrubRuntime(t)
|
||||
store := rt.Store()
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
evt := &ContextEvent{
|
||||
@ -108,7 +113,10 @@ func TestScrubEventCoversMemoryBlocks(t *testing.T) {
|
||||
}
|
||||
evt.Blocks = append(evt.Blocks, memoryBlockWithText("转写稿里有 "+tok))
|
||||
|
||||
scrubEventSecrets(evt)
|
||||
// ★ 2026-11-04:改走真实注入路径(Context.secretStore)。
|
||||
rc := NewRelevanceContext("", nil)
|
||||
rc.SetSecretStore(store) // 必须用**同一个** Store(否则变量登记在读的那一份里)
|
||||
rc.scrubEventSecrets(evt)
|
||||
|
||||
for name, got := range map[string]string{
|
||||
"input": evt.Input,
|
||||
@ -172,7 +180,8 @@ func (p *echoTokenProvider) ChatStream(_ context.Context, _ *agentAPI.Completion
|
||||
// `scrubMemoryCandidate` —— 初版直接调底层函数,于是"去掉入口处的调用"
|
||||
// 这个变异**不会报红**(判据测错了层次)。走真实入口才能钉住"入口必须调它"。
|
||||
func TestEmitMemoryCandidateScrubsPayload(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
rt := newScrubRuntime(t)
|
||||
store := rt.Store()
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
a := New(AgentConfig{
|
||||
@ -181,6 +190,7 @@ func TestEmitMemoryCandidateScrubsPayload(t *testing.T) {
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
Security: rt, // 必须与 store 同源
|
||||
})
|
||||
|
||||
// 故意传**未经过任何上游清洗**的原始值
|
||||
|
||||
@ -7,6 +7,7 @@ package core
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@ -22,6 +23,16 @@ import (
|
||||
|
||||
// newRootWith 造一个带完整图记忆的父 agent,并指定它(以及它的驻留子)用的 provider。
|
||||
func newRootWith(t *testing.T, provider agentAPI.Provider) (*Agent, *memory.GraphDB, string) {
|
||||
t.Helper()
|
||||
return newRootWithSecurity(t, provider, nil)
|
||||
}
|
||||
|
||||
// newRootWithSecurity 造根 agent,并把 security Runtime 装进**装配期字段**。
|
||||
//
|
||||
// ★ 2026-11-04:包级单例(security.Global)已删除,变量库的唯一来源是
|
||||
// AgentConfig.Security → a.security → a.secretStore()。所有脱敏判据都必须
|
||||
// 经这条路径,否则测的是"全局恰好有值"而不是接线。
|
||||
func newRootWithSecurity(t *testing.T, provider agentAPI.Provider, rt *security.Runtime) (*Agent, *memory.GraphDB, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
|
||||
@ -36,6 +47,7 @@ func newRootWith(t *testing.T, provider agentAPI.Provider) (*Agent, *memory.Grap
|
||||
StageHost: NewStageHost(),
|
||||
Memory: main,
|
||||
DataDir: dir,
|
||||
Security: rt,
|
||||
})
|
||||
a.Start() // 父也有自己的调度器:子的消息要真的进它的中断队列并被处理
|
||||
t.Cleanup(func() { a.Stop(); main.Close() })
|
||||
|
||||
@ -43,7 +43,7 @@ import (
|
||||
)
|
||||
|
||||
func (a *Agent) executeSecretTool(tc agentAPI.ToolCall) string {
|
||||
s := security.Global()
|
||||
s := a.secretStore()
|
||||
if s == nil {
|
||||
return "安全模块未启用(core.security.enabled=false),变量库不可用"
|
||||
}
|
||||
|
||||
@ -18,13 +18,14 @@ const c8Token = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
func newSecretToolAgent(t *testing.T) (*Agent, *security.Store) {
|
||||
t.Helper()
|
||||
store := newScrubStore(t) // 复用已有的测试夹具
|
||||
a := New(AgentConfig{
|
||||
rt := newScrubRuntime(t) // 复用已有的测试夹具
|
||||
// 按真实装配路径注入:AgentConfig.Security → a.security → a.secretStore()
|
||||
a := withScrubRuntime(New(AgentConfig{
|
||||
ID: "c8",
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
StageHost: NewStageHost(),
|
||||
})
|
||||
return a, store
|
||||
}), rt)
|
||||
return a, rt.Store()
|
||||
}
|
||||
|
||||
func callSecret(a *Agent, name string, args map[string]interface{}) string {
|
||||
@ -131,7 +132,6 @@ func TestSecretSubmitRejectsEmpty(t *testing.T) {
|
||||
func TestSecretToolsReportUnavailable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_ = dir
|
||||
security.SetGlobal(nil)
|
||||
defer newScrubStore(t) // 复原给后续判据
|
||||
|
||||
a := New(AgentConfig{ID: "c8-off", StageHost: NewStageHost()})
|
||||
@ -143,7 +143,6 @@ func TestSecretToolsReportUnavailable(t *testing.T) {
|
||||
|
||||
// ★ 判据:未启用时**不向模型暴露**这些工具(避免无谓往返)。
|
||||
func TestSecretToolsHiddenWhenDisabled(t *testing.T) {
|
||||
security.SetGlobal(nil)
|
||||
a := New(AgentConfig{ID: "c8-hidden", StageHost: NewStageHost()})
|
||||
|
||||
var names []string
|
||||
@ -167,8 +166,10 @@ func TestSecretToolsHiddenWhenDisabled(t *testing.T) {
|
||||
|
||||
// ★ 判据:启用时**要暴露**(否则模型不知道能用)。
|
||||
func TestSecretToolsExposedWhenEnabled(t *testing.T) {
|
||||
newScrubStore(t)
|
||||
a := New(AgentConfig{ID: "c8-shown", StageHost: NewStageHost()})
|
||||
a := withScrubRuntime(
|
||||
New(AgentConfig{ID: "c8-shown", StageHost: NewStageHost()}),
|
||||
newScrubRuntime(t),
|
||||
)
|
||||
|
||||
got := map[string]bool{}
|
||||
for _, d := range a.buildToolDefs() {
|
||||
|
||||
@ -21,7 +21,6 @@ import (
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
)
|
||||
|
||||
// ★★ 判据:**多 agent 并发出向** —— 明文一律不得泄漏到 provider。
|
||||
@ -29,7 +28,8 @@ import (
|
||||
// 每个 agent 有自己的 provider 实例,但它们共享**同一个**
|
||||
// ProviderManager 装饰器与变量库单例(这正是设计要验的点)。
|
||||
func TestConcurrency_MultiAgentOutboundScrub(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
rt := newScrubRuntime(t)
|
||||
store := rt.Store()
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
const agents = 16
|
||||
@ -44,6 +44,9 @@ func TestConcurrency_MultiAgentOutboundScrub(t *testing.T) {
|
||||
defer wg.Done()
|
||||
var captured string
|
||||
mgr := agentAPI.NewProviderManager()
|
||||
// ★ 注入必须**早于 Register**:装饰器在 Register 时套上。
|
||||
// 此前这行漏了,判据如实报红(640 次泄漏)。
|
||||
mgr.SetSecretStore(store)
|
||||
mgr.Register("p", &fwCaptureProvider{name: "p", capture: &captured})
|
||||
p := mgr.Default()
|
||||
|
||||
@ -83,8 +86,9 @@ func TestConcurrency_MultiAgentOutboundScrub(t *testing.T) {
|
||||
// 真实风险:A 的工具参数里的别名被填成 B 的凭据(串号),
|
||||
// 这会造成"用了错的凭据"且**不报错**。
|
||||
func TestConcurrency_ToolArgsDoNotCrossTalk(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
a, _, _ := newRootForResidents(t)
|
||||
rt := newScrubRuntime(t)
|
||||
store := rt.Store()
|
||||
a, _, _ := newRootWithSecurity(t, &countingProvider{}, rt)
|
||||
|
||||
// 每个 goroutine 用**自己**的凭据与别名
|
||||
const workers = 12
|
||||
@ -144,13 +148,14 @@ func TestConcurrency_ToolArgsDoNotCrossTalk(t *testing.T) {
|
||||
|
||||
// ★★ 判据:**并发上下文写入**(Append 咽喉的 scrub)不丢不串。
|
||||
func TestConcurrency_ConcurrentContextScrub(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
rt := newScrubRuntime(t)
|
||||
store := rt.Store()
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
if _, err := store.Submit(tok, "ctx"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a, _, _ := newRootForResidents(t)
|
||||
a, _, _ := newRootWithSecurity(t, &countingProvider{}, rt)
|
||||
|
||||
const writers = 10
|
||||
const perWriter = 50
|
||||
@ -197,7 +202,8 @@ func TestConcurrency_ConcurrentContextScrub(t *testing.T) {
|
||||
|
||||
// ★★ 判据:**并发记忆候选**(emitMemoryCandidate 的 scrub)。
|
||||
func TestConcurrency_ConcurrentMemoryCandidate(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
rt := newScrubRuntime(t)
|
||||
store := rt.Store()
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
if _, err := store.Submit(tok, "mem"); err != nil {
|
||||
t.Fatal(err)
|
||||
@ -209,6 +215,7 @@ func TestConcurrency_ConcurrentMemoryCandidate(t *testing.T) {
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
Security: rt, // 必须与 store 同源
|
||||
})
|
||||
|
||||
const n = 200
|
||||
@ -265,10 +272,8 @@ done:
|
||||
|
||||
// ★ 判据:并发守卫(未启用安全模块)时零成本且不 panic。
|
||||
func TestConcurrency_DisabledIsSafe(t *testing.T) {
|
||||
security.SetGlobal(nil)
|
||||
t.Cleanup(func() { newScrubStore(t) })
|
||||
|
||||
a, _, _ := newRootForResidents(t)
|
||||
// 未启用安全模块:显式传 nil Runtime(不是"忘了注入")。
|
||||
a, _, _ := newRootWithSecurity(t, &countingProvider{}, nil)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
|
||||
@ -346,3 +346,22 @@ func (a *Agent) reviewToolResultContent(tool, text string) []security.Finding {
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
// secretStore 返回本 agent 的**变量库**(凭据别名),未启用时为 nil。
|
||||
//
|
||||
// ★ 2026-11-04:取代 `security.Global()` 包级单例。
|
||||
//
|
||||
// 单例的问题不是"多一处全局变量",而是**它绕开了统一挂载点**:
|
||||
// 本 agent 的 a.security 是装配期从内核传来的那个 Runtime,而
|
||||
// security.Global() 是另一个独立变量。两者在正常装配下恰好指向同一份
|
||||
// 数据,但"恰好"不是保证 —— 测试替身、多 Runtime、热重载任一路径下
|
||||
// 都可能分离,而分离的症状是"策略生效但凭据替换不生效"(或反之),
|
||||
// 极难定位。
|
||||
//
|
||||
// 现在只有一个事实来源:本 agent 的 security 字段。
|
||||
func (a *Agent) secretStore() *security.Store {
|
||||
if a == nil || a.security == nil {
|
||||
return nil
|
||||
}
|
||||
return a.security.Store()
|
||||
}
|
||||
|
||||
@ -16,12 +16,23 @@ import (
|
||||
type StageHost struct {
|
||||
// frozenFn 报告内核是否处于伪关机(迁移中)。nil = 永不冻结。
|
||||
// 用函数而不是直接引用 plugin.Registry,避免 agent/core 反向依赖 plugin 包。
|
||||
frozenFn func() bool
|
||||
mu sync.RWMutex
|
||||
toolDefs []sdk.ToolDef
|
||||
tools map[string]sdk.ToolHandler
|
||||
toolPlugins map[string]string
|
||||
stages map[sdk.Stage][]stageEntry
|
||||
frozenFn func() bool
|
||||
|
||||
// secretStore 是**变量库**(凭据别名),用于插件工具参数的回填。
|
||||
//
|
||||
// ★ 为什么是注入字段而不是 security.Global()(2026-11-04):
|
||||
// 包级单例绕开统一挂载点。StageHost 属于某个 agent,就该用那个 agent
|
||||
// 的变量库;单例在正常装配下"恰好"同一份,但测试替身/多 Runtime/
|
||||
// 热重载任一路径都可能分离,症状是"策略生效但回填不生效",极难定位。
|
||||
//
|
||||
// nil 合法:未启用安全模块时回填零成本跳过。
|
||||
secretStoreMu sync.RWMutex
|
||||
secretStoreV *security.Store
|
||||
mu sync.RWMutex
|
||||
toolDefs []sdk.ToolDef
|
||||
tools map[string]sdk.ToolHandler
|
||||
toolPlugins map[string]string
|
||||
stages map[sdk.Stage][]stageEntry
|
||||
|
||||
// cancelable 记录本工具的执行体是否**接收取消信号**。
|
||||
//
|
||||
@ -59,6 +70,26 @@ func NewStageHost() *StageHost {
|
||||
}
|
||||
}
|
||||
|
||||
// SetSecretStore 注入变量库(装配期由 Agent 调用)。
|
||||
func (h *StageHost) SetSecretStore(s *security.Store) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.secretStoreMu.Lock()
|
||||
h.secretStoreV = s
|
||||
h.secretStoreMu.Unlock()
|
||||
}
|
||||
|
||||
// secretStore 返回注入的变量库(未注入为 nil)。
|
||||
func (h *StageHost) secretStore() *security.Store {
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
h.secretStoreMu.RLock()
|
||||
defer h.secretStoreMu.RUnlock()
|
||||
return h.secretStoreV
|
||||
}
|
||||
|
||||
func (h *StageHost) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@ -251,7 +282,7 @@ func (h *StageHost) ExecuteToolCtx(ctx context.Context, name string, args map[st
|
||||
//
|
||||
// 为何必须填而不是拒绝:模型手上只有别名(它从未见过明文),
|
||||
// 而插件需要真凭据。回填是二者之间唯一的通道。
|
||||
if s := security.Global(); s != nil && args != nil {
|
||||
if s := h.secretStore(); s != nil && args != nil {
|
||||
if n, used := s.FillMap(args); n > 0 {
|
||||
log.Printf("[security] tool %s backfilled %d argument(s) (aliases %v)", name, n, used)
|
||||
}
|
||||
|
||||
@ -1068,7 +1068,7 @@ func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome {
|
||||
// 可能被直接传明文(例如模型自己拼了一个 key)。日志是永久的,
|
||||
// 一旦落盘就再也收不回来。
|
||||
log.Printf("[agent] tool %s raw_arguments: %s", tc.Name,
|
||||
scrubForLog(truncateStr(tc.RawArguments, 300), "raw_args:"+tc.Name))
|
||||
a.scrubForLog(truncateStr(tc.RawArguments, 300), "raw_args:"+tc.Name))
|
||||
}
|
||||
|
||||
sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
|
||||
@ -1170,7 +1170,7 @@ func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
|
||||
// ★ 工具输出可能含服务端回显的凭据(实测场景:enc_shell 跑 curl 拿 200,
|
||||
// 响应体里带着上游 key 的完整值)。这一行以前原样打印。
|
||||
log.Printf("[agent] tool %s result: %s", f.CurTool.Name,
|
||||
scrubForLog(truncateStr(result, 100), "tool_result:"+f.CurTool.Name))
|
||||
a.scrubForLog(truncateStr(result, 100), "tool_result:"+f.CurTool.Name))
|
||||
|
||||
// Success 此前是**唯一**赋值点且硬编码 true ⇒ 该字段恒真、结构上不可能为
|
||||
// false。工具失败是以 nil error + 错误**值**返回的,所以判据必须看返回值。
|
||||
|
||||
@ -184,7 +184,7 @@ func (a *Agent) executeToolCallOutcome(tc agentAPI.ToolCall, channel string, tur
|
||||
// 设备授权拒绝、不存在、panic),逐个加必然漏。defer 是唯一
|
||||
// 能覆盖全部分支的位置,且 panic 路径也一起覆盖。
|
||||
if out.Text != "" {
|
||||
if st := security.Global(); st != nil {
|
||||
if st := a.secretStore(); st != nil {
|
||||
if clean, evts := st.Replace(out.Text, "tool_out:"+tc.Name, security.Patterns); len(evts) > 0 {
|
||||
out.Text = clean
|
||||
for _, e := range evts {
|
||||
|
||||
@ -86,8 +86,11 @@ func TestToolCallArgumentsAreScrubbed(t *testing.T) {
|
||||
RawArguments: `{"cmd":"ghp_abcdefghijklmnopqrstuvwxyz0123456789"}`,
|
||||
}},
|
||||
}
|
||||
initScrubStore(t)
|
||||
scrubEventSecrets(evt)
|
||||
// ★ 2026-11-04:改走真实注入路径(Context.secretStore),
|
||||
// 不再依赖包级单例。
|
||||
rc := NewRelevanceContext("", nil)
|
||||
rc.SetSecretStore(initScrubRuntime(t).Store())
|
||||
rc.scrubEventSecrets(evt)
|
||||
|
||||
arg, _ := evt.ToolCalls[0].Arguments["cmd"].(string)
|
||||
if strings.Contains(arg, "ghp_abcdefghijklmnopqrstuvwxyz0123456789") {
|
||||
@ -98,19 +101,25 @@ func TestToolCallArgumentsAreScrubbed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// initScrubStore 装一个启用状态的变量库,供脱敏判据使用。
|
||||
// initScrubRuntime 建一个启用状态的**变量库 Runtime**(供脱敏判据使用)。
|
||||
//
|
||||
// 单独封装而不是内联:脱敏依赖进程级单例(security.Global),
|
||||
// 装配与清理成对出现才不会污染同包其他用例。
|
||||
func initScrubStore(t *testing.T) {
|
||||
// ★ 2026-11-04:取代 initScrubStore。
|
||||
//
|
||||
// 原实现装的是**包级单例**(security.InitGlobal / SetGlobal)。那个单例已被
|
||||
// 删除 —— 现在唯一的变量库来源是 Runtime(挂在 kstate.State.Security 上,
|
||||
// 再由 AgentConfig.Security → a.security 传进 agent)。
|
||||
//
|
||||
// 判据也必须按这条真实路径装,否则测的是"全局恰好有值",而不是接线。
|
||||
func initScrubRuntime(t *testing.T) *security.Runtime {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if _, err := security.InitGlobal(security.Options{
|
||||
s := security.New(security.Options{
|
||||
Path: dir + "/v.json",
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
})
|
||||
if err := s.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
return security.NewRuntime(s)
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ import (
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/meta"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
sdkmeta "github.com/JianFeeeee/hologramagentsdk/meta"
|
||||
)
|
||||
|
||||
@ -496,7 +495,7 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
//
|
||||
// 仅当安全模块真的启用时才暴露:未启用时变量库不可用,
|
||||
// 把工具给模型只会让它反复撞到"安全模块未启用"(无谓往返)。
|
||||
if security.Global() != nil {
|
||||
if a.secretStore() != nil {
|
||||
tools = append(tools, secretToolDefs()...)
|
||||
}
|
||||
|
||||
|
||||
@ -29,6 +29,19 @@ import (
|
||||
)
|
||||
|
||||
func newScrubStore(t *testing.T) *security.Store {
|
||||
t.Helper()
|
||||
return newScrubRuntime(t).Store()
|
||||
}
|
||||
|
||||
// newScrubRuntime 建一个**带变量库的 Runtime**,供测试注入 Agent。
|
||||
//
|
||||
// ★ 为什么返回 Runtime 而不是 Store:现在唯一的变量库来源是
|
||||
// Agent.security.Store()(包级单例已删)。测试必须按真实路径装:
|
||||
//
|
||||
// Runtime → AgentConfig.Security → a.security.Store()
|
||||
//
|
||||
// 直接返回 Store 会诱使调用方又去走全局单例,等于把刚删掉的东西加回来。
|
||||
func newScrubRuntime(t *testing.T) *security.Runtime {
|
||||
t.Helper()
|
||||
// 不用 t.TempDir():它的清理是**严格**的 RemoveAll,只要清理瞬间还有
|
||||
// 落盘写入(变量库 saveLocked 的 tmp→rename)就会报 "directory not empty",
|
||||
@ -41,16 +54,23 @@ func newScrubStore(t *testing.T) *security.Store {
|
||||
if err != nil {
|
||||
t.Fatalf("建临时目录失败: %v", err)
|
||||
}
|
||||
s, err := security.InitGlobal(security.Options{
|
||||
// ★ 2026-11-04:不再用 security.InitGlobal(包级单例)。
|
||||
//
|
||||
// 单例已被删除 —— 现在唯一的变量库来源是 **Agent 的 security 字段**
|
||||
// (见 core.secretStore)。测试也必须按这个路径装:
|
||||
//
|
||||
// Store → Runtime → AgentConfig.Security → a.security.Store()
|
||||
//
|
||||
// 这同时让判据更硬:它验证的是"真实注入路径",而不是"全局恰好有值"。
|
||||
s := security.New(security.Options{
|
||||
Path: dir + "/v.json",
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
if err := s.Load(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
security.SetGlobal(nil)
|
||||
// 50 × 20ms:足够让在途 saveLocked 收尾;仍不行就尽力删,不让清理失败
|
||||
// 把一个已经通过的安全判据变成假红。
|
||||
for i := 0; i < 50; i++ {
|
||||
@ -61,7 +81,17 @@ func newScrubStore(t *testing.T) *security.Store {
|
||||
}
|
||||
_ = os.RemoveAll(dir)
|
||||
})
|
||||
return s
|
||||
return security.NewRuntime(s)
|
||||
}
|
||||
|
||||
// withScrubRuntime 把一个 Runtime 装进 agent 的 security 字段。
|
||||
//
|
||||
// 装配期在真实内核里由 kernelmod/agent 的 AgentConfig.Security 完成;
|
||||
// 测试里没有内核,所以直接写字段 —— 但要保证**走的是同一条数据路径**
|
||||
// (a.security.Store()),而不是另建一个全局变量。
|
||||
func withScrubRuntime(a *Agent, rt *security.Runtime) *Agent {
|
||||
a.security = rt
|
||||
return a
|
||||
}
|
||||
|
||||
// ★★ 判据:进 a.context 的工具结果不含明文(走 agent 的真实代码路径)。
|
||||
@ -135,7 +165,8 @@ func TestToolResultsScrubDoesNotMutateSource(t *testing.T) {
|
||||
// Append 之后、或忘了给 emitMemoryCandidate 用过滤后的副本),
|
||||
// 只有这条会报红。
|
||||
func TestE2E_ToolResultSecretNeverReachesContext(t *testing.T) {
|
||||
store := newScrubStore(t)
|
||||
rt := newScrubRuntime(t)
|
||||
store := rt.Store()
|
||||
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
a := New(AgentConfig{
|
||||
@ -144,6 +175,8 @@ func TestE2E_ToolResultSecretNeverReachesContext(t *testing.T) {
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
// ★ 必须与 store 同源:判据最后要读 store.Count() 确认真的拦截了。
|
||||
Security: rt,
|
||||
})
|
||||
|
||||
// 注册一个会吐出凭据的工具(模拟 cmd_run 读了 .env)
|
||||
|
||||
Reference in New Issue
Block a user