diff --git a/internal/agent/api/provider.go b/internal/agent/api/provider.go index e2f50cc0..2a4739cd 100644 --- a/internal/agent/api/provider.go +++ b/internal/agent/api/provider.go @@ -1082,11 +1082,19 @@ type providerStatus struct { } type ProviderManager struct { - mu sync.RWMutex - providers map[string]Provider - order []string - default_ string - status map[string]*providerStatus + mu sync.RWMutex + // secretStore 是**变量库**(凭据别名),用于出向装饰。 + // + // ★ 为什么是注入字段而不是 security.Global()(2026-11-04): + // 包级单例绕开了内核的统一挂载点(kstate.State.Security)。 + // 现在由内核装配时调 SetSecretStore 注入;未注入时装饰器零成本跳过。 + // + // nil 合法:未启用安全模块、或测试未注入。 + secretStore *security.Store + providers map[string]Provider + order []string + default_ string + status map[string]*providerStatus // caps 是**配置层声明**的模态能力(源名 → vision/audio)。 // @@ -1183,13 +1191,33 @@ func (m *ProviderManager) Reset() { // 逐个改必漏;而且插件新增 provider 时无人在意去挂装饰器。 // // 安全模块未启用时 security.Global() 返回 nil ⇒ 装饰器完全不介入,零成本。 +// SetSecretStore 注入变量库(内核装配时调用一次)。 +// +// 注入后**已注册的 provider 不会补装饰**:装饰发生在 Register 时。 +// 因此内核必须在注册任何 provider 之前调用本方法(llm 模块的 Init 顺序保证)。 +func (m *ProviderManager) SetSecretStore(s *security.Store) { + if m == nil { + return + } + m.mu.Lock() + m.secretStore = s + m.mu.Unlock() +} + +// secretStoreOf 读注入的变量库(无锁快照,Register 内部用)。 +func (m *ProviderManager) secretStoreOf() *security.Store { + m.mu.RLock() + defer m.mu.RUnlock() + return m.secretStore +} + func (m *ProviderManager) Register(name string, p Provider) { // 出向明文 → 别名(防泄漏)。未启用时原样透传。 // // 守卫:已是本层装饰过的实例就不再包一层。 // 双重包裹本身无害(第二遍看到的已无明文,是空转),但会白跑一遗 // 正则扫描 —— 而热路径上每条消息都扫一遍,不值得。 - if store := security.Global(); store != nil { + if store := m.secretStoreOf(); store != nil { if _, alreadyWrapped := p.(*secureProviderDecorator); !alreadyWrapped { p = WrapProviderForSecurity(p, store) } diff --git a/internal/agent/api/security_premise_test.go b/internal/agent/api/security_premise_test.go index d34f8d8d..1ecdcd94 100644 --- a/internal/agent/api/security_premise_test.go +++ b/internal/agent/api/security_premise_test.go @@ -220,7 +220,7 @@ func (p *rewriteProvider) MaxContextTokens() int { return p.inner.MaxContextToke // ③ 替换真的发生了 func TestWired_RegisterDecoratesAndScrubs(t *testing.T) { dir := t.TempDir() - store, err := security.InitGlobal(security.Options{ + store, err := security.MustStore(security.Options{ Path: filepath.Join(dir, "vars.json"), KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, Enabled: true, @@ -228,13 +228,14 @@ func TestWired_RegisterDecoratesAndScrubs(t *testing.T) { if err != nil { t.Fatal(err) } - t.Cleanup(func() { security.SetGlobal(nil) }) const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" var got string inner := &recordingProvider{name: "wired", capture: &got} m := NewProviderManager() + // ★ 注入必须先于 Register(装饰器在 Register 时套上)。 + m.SetSecretStore(store) m.Register("p", inner) // 取出(任何一条取用路径都应拿到装饰后的实例) @@ -265,8 +266,6 @@ func TestWired_RegisterDecoratesAndScrubs(t *testing.T) { // ★ 判据:安全模块未初始化时,Register **不介入**(零成本)。 func TestWired_NoStoreMeansNoInterference(t *testing.T) { - security.SetGlobal(nil) - t.Cleanup(func() { security.SetGlobal(nil) }) const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" var got string @@ -285,7 +284,7 @@ func TestWired_NoStoreMeansNoInterference(t *testing.T) { // ★ 判据:回填把别名换回明文(入向通道)。 func TestWired_FillToolArgs(t *testing.T) { dir := t.TempDir() - store, err := security.InitGlobal(security.Options{ + store, err := security.MustStore(security.Options{ Path: filepath.Join(dir, "vars.json"), KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, Enabled: true, @@ -293,7 +292,6 @@ func TestWired_FillToolArgs(t *testing.T) { if err != nil { t.Fatal(err) } - t.Cleanup(func() { security.SetGlobal(nil) }) const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" alias, err := store.Submit(tok, "") @@ -301,8 +299,13 @@ func TestWired_FillToolArgs(t *testing.T) { t.Fatal(err) } + // ★ 2026-11-04:改为验证**真实回填路径**。 + // + // 原判据调 security.FillToolArgs —— 那函数读包级单例、生产调用者为 0, + // 已随单例一起删除。真实路径是 core.StageHost.ExecuteToolCtx(用注入的 + // Store 的 FillMap)。这里验证同一个能力面:Store.FillMap 把别名换回明文。 args := map[string]interface{}{"token": "@{" + alias + "}", "other": "unchanged"} - n, used := security.FillToolArgs("some_plugin_tool", args) + n, used := store.FillMap(args) if n != 1 { t.Fatalf("应回填 1 处,实际 %d", n) } diff --git a/internal/agent/api/security_wiring_test.go b/internal/agent/api/security_wiring_test.go index 431f36f0..17a4bf0b 100644 --- a/internal/agent/api/security_wiring_test.go +++ b/internal/agent/api/security_wiring_test.go @@ -32,7 +32,7 @@ import ( // (而不是 Register),那么后注册的 provider 就会漏掉。 func TestLateRegistrationStillDecorated(t *testing.T) { dir := t.TempDir() - store, err := security.InitGlobal(security.Options{ + store, err := security.MustStore(security.Options{ Path: filepath.Join(dir, "v.json"), KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, Enabled: true, @@ -40,10 +40,11 @@ func TestLateRegistrationStillDecorated(t *testing.T) { if err != nil { t.Fatal(err) } - t.Cleanup(func() { security.SetGlobal(nil) }) const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" m := NewProviderManager() + // ★ 注入必须先于 Register(装饰器在 Register 时套上)。 + m.SetSecretStore(store) // ① 先注册一个 var first string @@ -80,16 +81,18 @@ func TestLateRegistrationStillDecorated(t *testing.T) { // ★ 判据:**已装饰的不再包第二层**(避免热路径白跑)。 func TestNoDoubleWrapping(t *testing.T) { dir := t.TempDir() - if _, err := security.InitGlobal(security.Options{ + store, err := security.MustStore(security.Options{ Path: filepath.Join(dir, "v.json"), KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, Enabled: true, - }); err != nil { + }) + if err != nil { t.Fatal(err) } - t.Cleanup(func() { security.SetGlobal(nil) }) m := NewProviderManager() + // ★ 注入必须先于 Register(装饰器在 Register 时套上)。 + m.SetSecretStore(store) base := &recordingProvider{name: "x", capture: new(string)} m.Register("a", base) inner := m.Get("a") @@ -118,21 +121,33 @@ func TestMainInitsSecurityBeforeProviders(t *testing.T) { text := string(src) // ★ 2026-10-26:安全装配搬进正式内核模块 internal/kernelmod/security。 - // main.go 里的真实装配点是 `securitymod.Prepare(...)`,而 - // `security.InitGlobal` 在模块实现里。判据必须同时盯住**两个真实点**: - // · main.go 的 Prepare 调用(装配时机) - // · 模块实现里的 InitGlobal(Prepare 不是空壳) + // ★ 2026-11-04:包级单例删除,`InitGlobal` 改为 `MustStore` + // (构造并加载 Store,不注册全局)。 + // + // 判据必须同时盯住**两个真实点**: + // · 模块实现里的 Prepare(装配时机与产物:ks.Security) + // · 模块实现里的 MustStore(Prepare 不是空壳) + // // 只盯其中一个都会变成"改了实现就静默变绿"。 - iSec := strings.Index(text, "securitymod.Prepare(") - if iSec < 0 { - iSec = strings.Index(text, "security.InitGlobal") - } modSrc, modErr := os.ReadFile("../../kernelmod/security/mod.go") - modText := string(modSrc) - if modErr != nil || !strings.Contains(modText, "sec.InitGlobal(") { - t.Fatalf("security 模块实现里找不到 InitGlobal(%v)—— "+ - "Prepare() 退化成空壳,安全审查不会生效", modErr) + if modErr != nil { + t.Fatalf("读不到 security 模块实现: %v", modErr) } + modText := string(modSrc) + if !strings.Contains(modText, "sec.MustStore(") { + t.Fatalf("security 模块实现里找不到 sec.MustStore —— " + + "Prepare() 退化成空壳,安全审查不会生效") + } + if !strings.Contains(modText, "ks.Security = ") { + t.Fatalf("security 模块没有把 Runtime 挂到 ks.Security —— " + + "变量库/防火墙对其它模块不可见") + } + // 模块自身的装配点(Prepare 在 main.go 或模块内被调用)。 + iSec := strings.Index(modText, "func Prepare(") + if iSec < 0 { + t.Fatalf("security 模块里找不到 Prepare —— 装配入口没了") + } + _ = text // ★ 锚点已随"内核模块化"改写(2026-10-16)。 // // 原先锚 `initLLMProviders(cfg,`(那时它直接写在 main 里)。 diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 6f92b14d..b873f4ec 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -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 接管就不再进本内核队列。 diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index d50607a5..8b604381 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -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:认领待登记的落盘记录 diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 1ae8cd4e..986e7811 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -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) } diff --git a/internal/agent/core/encshell_premise_test.go b/internal/agent/core/encshell_premise_test.go index 16baa7ed..48e40443 100644 --- a/internal/agent/core/encshell_premise_test.go +++ b/internal/agent/core/encshell_premise_test.go @@ -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, "测") diff --git a/internal/agent/core/encshell_redundancy_test.go b/internal/agent/core/encshell_redundancy_test.go index 9dc4e954..bc7cb3d3 100644 --- a/internal/agent/core/encshell_redundancy_test.go +++ b/internal/agent/core/encshell_redundancy_test.go @@ -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, "测试") diff --git a/internal/agent/core/firewall_e2e_test.go b/internal/agent/core/firewall_e2e_test.go index fb43ea9b..5467e95a 100644 --- a/internal/agent/core/firewall_e2e_test.go +++ b/internal/agent/core/firewall_e2e_test.go @@ -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" diff --git a/internal/agent/core/inplace_scrub_test.go b/internal/agent/core/inplace_scrub_test.go index b9e2a0f8..d21af46f 100644 --- a/internal/agent/core/inplace_scrub_test.go +++ b/internal/agent/core/inplace_scrub_test.go @@ -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(), diff --git a/internal/agent/core/leakback_test.go b/internal/agent/core/leakback_test.go index 3b19f08f..a215946b 100644 --- a/internal/agent/core/leakback_test.go +++ b/internal/agent/core/leakback_test.go @@ -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: "故意回吐明文", diff --git a/internal/agent/core/log_scrub.go b/internal/agent/core/log_scrub.go index ef2ec9c9..f5f58f7b 100644 --- a/internal/agent/core/log_scrub.go +++ b/internal/agent/core/log_scrub.go @@ -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 } diff --git a/internal/agent/core/log_scrub_test.go b/internal/agent/core/log_scrub_test.go index ad7e7edd..1bac2a73 100644 --- a/internal/agent/core/log_scrub_test.go +++ b/internal/agent/core/log_scrub_test.go @@ -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 时不该返回空") } diff --git a/internal/agent/core/memory_scrub.go b/internal/agent/core/memory_scrub.go index 919c2418..3fe36689 100644 --- a/internal/agent/core/memory_scrub.go +++ b/internal/agent/core/memory_scrub.go @@ -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 } diff --git a/internal/agent/core/output.go b/internal/agent/core/output.go index 71053c31..98aa424e 100644 --- a/internal/agent/core/output.go +++ b/internal/agent/core/output.go @@ -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) diff --git a/internal/agent/core/persist_leak_test.go b/internal/agent/core/persist_leak_test.go index ac07041d..5b80a31f 100644 --- a/internal/agent/core/persist_leak_test.go +++ b/internal/agent/core/persist_leak_test.go @@ -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 同源 }) // 故意传**未经过任何上游清洗**的原始值 diff --git a/internal/agent/core/resident_test.go b/internal/agent/core/resident_test.go index e3700770..e7ae0bd8 100644 --- a/internal/agent/core/resident_test.go +++ b/internal/agent/core/resident_test.go @@ -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() }) diff --git a/internal/agent/core/secret_tools.go b/internal/agent/core/secret_tools.go index 1519bf04..95fc4631 100644 --- a/internal/agent/core/secret_tools.go +++ b/internal/agent/core/secret_tools.go @@ -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),变量库不可用" } diff --git a/internal/agent/core/secret_tools_test.go b/internal/agent/core/secret_tools_test.go index 3dead6f3..4899e3c1 100644 --- a/internal/agent/core/secret_tools_test.go +++ b/internal/agent/core/secret_tools_test.go @@ -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() { diff --git a/internal/agent/core/security_concurrency_test.go b/internal/agent/core/security_concurrency_test.go index 8f2a24d5..60b12c44 100644 --- a/internal/agent/core/security_concurrency_test.go +++ b/internal/agent/core/security_concurrency_test.go @@ -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++ { diff --git a/internal/agent/core/security_profile.go b/internal/agent/core/security_profile.go index f9e2c8d8..c30926c4 100644 --- a/internal/agent/core/security_profile.go +++ b/internal/agent/core/security_profile.go @@ -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() +} diff --git a/internal/agent/core/stages.go b/internal/agent/core/stages.go index 7d5cb193..32b6d1b1 100644 --- a/internal/agent/core/stages.go +++ b/internal/agent/core/stages.go @@ -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) } diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index 1f01c80d..c5b82f5c 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -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 + 错误**值**返回的,所以判据必须看返回值。 diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index f4e11a58..3f6858df 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -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 { diff --git a/internal/agent/core/toolcall_record_test.go b/internal/agent/core/toolcall_record_test.go index f2a60d08..f17fdfd8 100644 --- a/internal/agent/core/toolcall_record_test.go +++ b/internal/agent/core/toolcall_record_test.go @@ -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) } diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 1fe62744..478d11f7 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -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()...) } diff --git a/internal/agent/core/toolresult_scrub_test.go b/internal/agent/core/toolresult_scrub_test.go index 954ead58..2e6fa4ad 100644 --- a/internal/agent/core/toolresult_scrub_test.go +++ b/internal/agent/core/toolresult_scrub_test.go @@ -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) diff --git a/internal/kernelmod/all/runtime_dep_test.go b/internal/kernelmod/all/runtime_dep_test.go index 63ecd257..2428bb3c 100644 --- a/internal/kernelmod/all/runtime_dep_test.go +++ b/internal/kernelmod/all/runtime_dep_test.go @@ -156,7 +156,12 @@ func TestRunRejectsLayerOrderViolation(t *testing.T) { // ② 指名道姓:llm 缺 lua、text-memory 缺 memory/onnx、knowledge-store 缺迁移标记。 want := map[string][]string{ - "llm": {kernelmod.FacLua}, + // ★ 2026-11-04:llm 新增了 FacSecurity 依赖。 + // 理由:ProviderManager.Register 在注册每个 provider 时套出向装饰器 + // (明文→别名),而装饰器需要变量库。单例删除后变量库必须**在注册前** + // 由 SetSecretStore 注入 ⇒ 依赖图必须保证 security 先完成, + // 不能靠"碰巧先装了单例"。 + "llm": {kernelmod.FacLua, kernelmod.FacSecurity}, "text-memory": {kernelmod.FacMemory, kernelmod.FacONNX}, "knowledge-store": {kernelmod.FacMigrated}, } diff --git a/internal/kernelmod/llm/mod.go b/internal/kernelmod/llm/mod.go index a125631e..6c809e0c 100644 --- a/internal/kernelmod/llm/mod.go +++ b/internal/kernelmod/llm/mod.go @@ -4,6 +4,7 @@ package llm import ( + "github.com/JianFeeeee/HologramAgent/internal/security" "log" "os" @@ -17,8 +18,18 @@ import ( // Module 提供多源 provider 管理。 type Module struct{} -func (Module) Name() string { return "llm" } -func (Module) Requires() []string { return []string{kernelmod.FacConfig, kernelmod.FacLua} } +func (Module) Name() string { return "llm" } +func (Module) Requires() []string { + // ★ FacSecurity(2026-11-04 补):这是**真实存在**的依赖。 + // + // ProviderManager.Register 在注册每个 provider 时套出向装饰器(明文→别名), + // 而装饰器需要变量库。此前那份变量库来自进程级单例 security.Global() —— + // 装配晚了也"恰好"能读到(因为单例先装了),于是顺序错误被掩盖。 + // + // 单例删除后,变量库必须**在注册前**由 SetSecretStore 注入。声明依赖 + // FacSecurity 让依赖图保证 security 先于本模块完成,而不是靠"碰巧"。 + return []string{kernelmod.FacConfig, kernelmod.FacLua, kernelmod.FacSecurity} +} func (Module) Provides() []string { return []string{kernelmod.FacLLM} } func (Module) Init(ks *kstate.State) error { @@ -30,15 +41,24 @@ func (Module) Init(ks *kstate.State) error { // // 实测:该装配**就在 initLLMProviders 内部**(不是 main 的独立语句) // ⇒ 声明式下它天然"紧邻",不再依赖 main 里那两行的相对位置。 + // ★ 注入必须**早于任何 provider 注册**:Register 时才会套装饰器, + // 注入晚了先注册的那批就没被装饰(静默漏掉一条出网路径)。 + // + // 依赖图(Requires 里的 FacSecurity)保证 ks.Security 此时非 nil。 ks.BaseAPIKey = resolveBaseAPIKey(ks.Cfg) - ks.LLMManager = buildProviderManager(ks.Cfg, ks.LuaVM, ks.BaseAPIKey) + ks.LLMManager = buildProviderManager(ks.Cfg, ks.LuaVM, ks.BaseAPIKey, ks.Security) ks.LLMProvider = ks.LLMManager.Default() return nil } // buildProviderManager 按配置注册全部 LLM 源(原 cmd/homed 的整块平移)。 -func buildProviderManager(cfg *types.Config, luaVM *luapkg.VM, baseAPIKey string) *api.ProviderManager { +func buildProviderManager(cfg *types.Config, luaVM *luapkg.VM, baseAPIKey string, + secRT *security.Runtime) *api.ProviderManager { providerMgr := api.NewProviderManager() + // 先注入变量库,再注册 —— 顺序反了会静默漏装饰(见 Init 的注释)。 + if secRT != nil { + providerMgr.SetSecretStore(secRT.Store()) + } adapterConcurrency := map[string]int{} for _, src := range cfg.LLM.Sources { if !api.IsValidSourceConfig(src.Name, src.BaseURL, src.Model, src.Adapter) { diff --git a/internal/kernelmod/pluginhost/mod.go b/internal/kernelmod/pluginhost/mod.go index 7cf27510..901bec27 100644 --- a/internal/kernelmod/pluginhost/mod.go +++ b/internal/kernelmod/pluginhost/mod.go @@ -307,6 +307,14 @@ func injectBaseDeps(ks *kstate.State) { } reg.SetTracker(ks.Tracker) reg.SetConfig(ks.Cfg) + // 变量库注入:sdk.SecretsAPI 桥接到它(不再读包级单例)。 + // ks.Security 可能为 nil(未启用安全模块),那正是"不可用"语义。 + if ks.Security != nil { + reg.SetSecretStore(ks.Security.Store()) + // 同一个 Runtime 也供插件的事件闸门与档位声明面使用 + // (原先是 sdk 自己读 RuntimeGlobal 包级单例)。 + reg.SetSecurityRuntime(ks.Security) + } // StageHost 在挂载点里存 any(kstate 不依赖 agent/core,否则成环)。 // 断言成 sdk.ToolSource(plugin 真正需要的只读工具视图)。 // diff --git a/internal/kernelmod/security/mod.go b/internal/kernelmod/security/mod.go index 99a672e4..26fe2de7 100644 --- a/internal/kernelmod/security/mod.go +++ b/internal/kernelmod/security/mod.go @@ -36,6 +36,7 @@ import ( "fmt" "log" "path/filepath" + "sync" "github.com/JianFeeeee/HologramAgent/internal/config" "github.com/JianFeeeee/HologramAgent/internal/events" @@ -44,6 +45,30 @@ import ( sec "github.com/JianFeeeee/HologramAgent/internal/security" ) +// 出参槽:Prepare 的幂等缓存(**装配器私有**,不对外暴露)。 +// +// ★ 与已删除的 security.RuntimeGlobal() 的区别: +// +// · 那个是**公开访问器**,任何包都能取 ⇒ 成了绕过 kstate 的第二条通道 +// · 这个是**本模块私有**,只用于"Prepare 只跑一次",产物同时挂到 +// ks.Security —— 外部一律从 ks 取,没有第二条通道 +var ( + preparedMu sync.RWMutex + prepared *sec.Runtime +) + +func preparedRuntime() *sec.Runtime { + preparedMu.RLock() + defer preparedMu.RUnlock() + return prepared +} + +func setPreparedRuntime(rt *sec.Runtime) { + preparedMu.Lock() + prepared = rt + preparedMu.Unlock() +} + // Module 提供安全防火墙设施。 type Module struct{} @@ -97,7 +122,16 @@ func init() { kernelmod.Register(Module{}) } // // 返回的 Runtime 里带着 Store 与 Gate(边界闸门)。 func Prepare(dataDir string, cfgReg *config.ConfigRegistry) (*sec.Runtime, error) { - if rt := sec.RuntimeGlobal(); rt != nil { + // 幂等:Group 0 与模块 Init 都会调它,但只能装一次。 + // + // ★ 为什么幂等状态放在**本模块**而不是 security 包(2026-11-04): + // 原先靠 `security.RuntimeGlobal()` 判断"装过没有",那让 security 包 + // 持有一份进程级单例 —— 而单例绕开了内核统一挂载点 + // (kstate.State.Security),导致"哪一份才是权威"取决于装配顺序。 + // + // 现在幂等状态是**装配器的私有状态**:它只保证 Prepare 只跑一次, + // 产物交给内核挂到 ks.Security,其它模块一律从 ks 取。 + if rt := preparedRuntime(); rt != nil { return rt, nil } if cfgReg == nil { @@ -107,7 +141,7 @@ func Prepare(dataDir string, cfgReg *config.ConfigRegistry) (*sec.Runtime, error // 显式关闭:装一个空 Runtime(所有闸门零成本透传), // 而不是留 nil 让调用点各自判断。 rt := sec.NewDisabledRuntime() - sec.SetRuntimeGlobal(rt) + setPreparedRuntime(rt) log.Printf("[security] module disabled by config (core.security.enabled=false)") return rt, nil } @@ -141,7 +175,12 @@ func Prepare(dataDir string, cfgReg *config.ConfigRegistry) (*sec.Runtime, error log.Printf("[security] loaded %d historical/extra key(s) (smooth rotation + embedded migration)", len(oldKeys)) } - store, err := sec.InitGlobal(sec.Options{ + // ★ 2026-11-04:改用 sec.New + Load,不再走 InitGlobal(包级单例已删)。 + // + // 变量库的唯一事实来源是**模块产出的 Runtime**(挂在 ks.Security 上), + // 各消费方从自己的注入面取(AgentConfig.Security / ProviderManager / + // plugin.Registry)。这条路径取代了原先"单例 + 各处 Global()"的双轨。 + store, err := sec.MustStore(sec.Options{ Path: filepath.Join(dataDir, "security", "variables.json"), KeyFn: func() ([]byte, error) { return key, nil }, OldKeys: oldKeys, @@ -169,7 +208,7 @@ func Prepare(dataDir string, cfgReg *config.ConfigRegistry) (*sec.Runtime, error } rt := sec.NewRuntime(store) - sec.SetRuntimeGlobal(rt) + setPreparedRuntime(rt) // ★ 全局策略基线(部署方兵底,用户 2026-10-29 裁定选项 B)。 // diff --git a/internal/plugin/proc/e2e_secrets_test.go b/internal/plugin/proc/e2e_secrets_test.go index 4b6e22f9..7dc41400 100644 --- a/internal/plugin/proc/e2e_secrets_test.go +++ b/internal/plugin/proc/e2e_secrets_test.go @@ -81,15 +81,15 @@ func TestE2E_RealTemplateSecretsRoundTrip(t *testing.T) { dir := t.TempDir() setTestCodec(t) - // 装配**进程级单例**(内核对所有插件共享的那一份) - if _, err := internalSecurity.InitGlobal(internalSecurity.Options{ + // 变量库(2026-11-04:不再有进程级单例,改为显式持有并注入) + store, err := internalSecurity.MustStore(internalSecurity.Options{ Path: filepath.Join(dir, "variables.json"), KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, Enabled: true, - }); err != nil { + }) + if err != nil { t.Fatal(err) } - t.Cleanup(func() { internalSecurity.SetGlobal(nil) }) _ = internalConfig.NewConfigRegistry host, err := NewHost() @@ -99,7 +99,7 @@ func TestE2E_RealTemplateSecretsRoundTrip(t *testing.T) { defer host.Close() core := newFakeCore() - core.secretsAPI = internalSDK.NewSecretsAPI() + core.secretsAPI = internalSDK.NewSecretsAPI(store) p := New("e2e", bin, dir, nil, host, nil) if err := p.Start(core); err != nil { t.Fatalf("Start: %v", err) @@ -126,9 +126,9 @@ func TestE2E_RealTemplateSecretsRoundTrip(t *testing.T) { if !strings.Contains(got, "e2e-secret-value-9876543210") { t.Fatalf("Fill 未换回明文: %q", got) } - // 单例里应真的登记了 - if internalSecurity.Global().Count() != 1 { - t.Fatalf("单例应有 1 条,实际 %d", internalSecurity.Global().Count()) + // 变量库里应真的登记了 + if store.Count() != 1 { + t.Fatalf("变量库应有 1 条,实际 %d", store.Count()) } t.Logf("✅ 插件经内核变量库完成往返: %s", got) } @@ -141,7 +141,7 @@ func TestE2E_PluginAndKernelShareOneVault(t *testing.T) { dir := t.TempDir() setTestCodec(t) - st, err := internalSecurity.InitGlobal(internalSecurity.Options{ + st, err := internalSecurity.MustStore(internalSecurity.Options{ Path: filepath.Join(dir, "variables.json"), KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, Enabled: true, @@ -149,12 +149,11 @@ func TestE2E_PluginAndKernelShareOneVault(t *testing.T) { if err != nil { t.Fatal(err) } - t.Cleanup(func() { internalSecurity.SetGlobal(nil) }) host, _ := NewHost() defer host.Close() core := newFakeCore() - core.secretsAPI = internalSDK.NewSecretsAPI() + core.secretsAPI = internalSDK.NewSecretsAPI(st) p := New("e2e", bin, dir, nil, host, nil) if err := p.Start(core); err != nil { t.Fatal(err) @@ -189,13 +188,12 @@ func TestE2E_SecretsUnavailableWhenDisabled(t *testing.T) { bin := buildPluginWithRealTemplate(t, secretsE2EPlugin) dir := t.TempDir() - internalSecurity.SetGlobal(nil) - t.Cleanup(func() { internalSecurity.SetGlobal(nil) }) host, _ := NewHost() defer host.Close() core := newFakeCore() - core.secretsAPI = internalSDK.NewSecretsAPI() + // 未启用安全模块 ⇒ 传 nil(Available() 必须为 false,且不得 panic) + core.secretsAPI = internalSDK.NewSecretsAPI(nil) p := New("e2e", bin, dir, nil, host, nil) if err := p.Start(core); err != nil { t.Fatal(err) diff --git a/internal/plugin/proc/e2e_security_declare_test.go b/internal/plugin/proc/e2e_security_declare_test.go index d954e747..6f50b7a3 100644 --- a/internal/plugin/proc/e2e_security_declare_test.go +++ b/internal/plugin/proc/e2e_security_declare_test.go @@ -75,11 +75,9 @@ func TestE2E_RealTemplateSecurityDeclare(t *testing.T) { dir := t.TempDir() setTestCodec(t) - // 装配进程级 Runtime(生产同款单例;工具判定读的就是它) - prev := internalSecurity.RuntimeGlobal() + // ★ 2026-11-04:包级单例已删。Runtime 显式构造并**注入**给插件 SDK + // (生产路径同款:kstate.State.Security → plugin.Registry.SetSecurityRuntime)。 rt := internalSecurity.NewRuntime(nil) - internalSecurity.SetRuntimeGlobal(rt) - t.Cleanup(func() { internalSecurity.SetRuntimeGlobal(prev) }) _ = internalConfig.NewConfigRegistry host, err := NewHost() @@ -90,7 +88,7 @@ func TestE2E_RealTemplateSecurityDeclare(t *testing.T) { core := newFakeCore() // ★ 用内核侧真实插件名注入 —— 这就是 Owner 不可伪造的来源。 - core.securityAPI = internalSDK.NewSecurityAPI("e2eproc") + core.securityAPI = internalSDK.NewSecurityAPI("e2eproc", rt) p := New("e2e", bin, dir, nil, host, nil) if err := p.Start(core); err != nil { t.Fatalf("Start: %v", err) diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index f10f99fd..302f8bf4 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/JianFeeeee/HologramAgent/internal/security" "log" "os" "path/filepath" @@ -79,10 +80,24 @@ type Registry struct { // kernelCtl 是内核重启/关机通道(装配时注入,可 nil)。 // 只对声明了该能力的内置插件可见(目前是 WebUI)。 kernelCtl sdk.KernelControlAPI - mu sync.RWMutex - plugins map[string]sdk.Plugin - instances []sdk.Plugin - factories map[string]NativeFactory + + // secretStore 是**变量库**(凭据别名),供 sdk.SecretsAPI 桥接。 + // + // ★ 为什么是注入而不是 security.Global()(2026-11-04): + // 包级单例已被删除(它绕开了内核统一挂载点)。现在由 plugin-host + // 模块从 ks.Security 取出来注入。 + // + // nil 合法:未启用安全模块时 SecretsAPI.Available() == false。 + secretStore *security.Store + + // securityRT 是安全运行时(sdk 的事件闸门 + 档位声明面需要)。 + // 与 secretStore 同源(都来自 kstate.State.Security),分两个字段是因为 + // 消费方要的东西不同:SecretsAPI 只要 Store,EventsAPI/DeclareProfile 要 Runtime。 + securityRT *security.Runtime + mu sync.RWMutex + plugins map[string]sdk.Plugin + instances []sdk.Plugin + factories map[string]NativeFactory pluginAutoRestart map[string]bool sdkRefs map[string]*sdk.PluginSDK @@ -330,6 +345,21 @@ func (r restartOnly) RequestKernelShutdown(reason string) error { // ★ 为何用 setter 而不是构造参数:internal/plugin 不能 import cmd/homed // // (那是反向依赖,且 cmd 包不可被 import)。用接口 + setter 打破方向。 +// +// SetSecurityRuntime 注入安全运行时(plugin-host 模块装配时调用)。 +func (r *Registry) SetSecurityRuntime(rt *security.Runtime) { + r.mu.Lock() + defer r.mu.Unlock() + r.securityRT = rt +} + +// SetSecretStore 注入变量库(plugin-host 模块装配时调用)。 +func (r *Registry) SetSecretStore(s *security.Store) { + r.mu.Lock() + defer r.mu.Unlock() + r.secretStore = s +} + func (r *Registry) SetKernelControl(k sdk.KernelControlAPI) { r.mu.Lock() defer r.mu.Unlock() @@ -556,6 +586,8 @@ type sdkDeps struct { stageHost sdk.ToolSource idx *memory.Indexer kernelCtl sdk.KernelControlAPI + secretStore *security.Store + securityRT *security.Runtime } // snapshotDeps 在一次 RLock 内取走 buildSDK 需要的全部依赖。 @@ -585,6 +617,8 @@ func (r *Registry) snapshotDeps() sdkDeps { stageHost: r.stageHost, idx: r.idx, kernelCtl: r.kernelCtl, + secretStore: r.secretStore, + securityRT: r.securityRT, } } @@ -757,13 +791,14 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK { Knowledge: sdk.NewKnowledge(deps.ks), LLM: sdk.NewLLM(deps.mgr, deps.cfgReg, deps.lua, deps.baseKey), Settings: sett, - // ★ 变量库:桥接到进程级 security.Global()(**不另建存储**)。 - // 内核未启用安全模块时它 Available()==false(不是 nil)。 - Secrets: sdk.NewSecretsAPI(), + // ★ 变量库:桥接到**注入的** Store(不另建存储)。 + // 内核未启用安全模块时为 nil ⇒ Available()==false(不是 nil API)。 + Secrets: sdk.NewSecretsAPI(deps.secretStore), + SecurityRuntime: deps.securityRT, // ★ 策略声明面:插件**声明**档位(qq.owner / qq.guest…), // 判定仍在内核 security.Runtime。Owner 由内核用真实插件名填, // 插件无法伪造身份(见 internal/sdk/security_impl.go)。 - Security: sdk.NewSecurityAPI(name), + Security: sdk.NewSecurityAPI(name, deps.securityRT), // ★ 随包资源:读 <插件目录>/rawfile/,可写目录 <插件目录>/work/。 // 内置插件(无独立目录)拿到的路径不存在,Read 会明确报错 —— // 那是对的:它们本来就没有随包资源。 diff --git a/internal/sdk/events.go b/internal/sdk/events.go index cd810b6d..e733e4da 100644 --- a/internal/sdk/events.go +++ b/internal/sdk/events.go @@ -65,8 +65,9 @@ type eventsAPI struct { // 返回 nil 会让 `s.Events().Publish(...)` 在未接总线的宿主里直接 panic // (实测:internal/plugins 的集成测试在 loadOne 阶段崩在 clawhubadapter 上)。 // 插件不该为"宿主没接总线"写 nil 判断——那是一个该报错、而不是该崩溃的状态。 -func newEventsAPI(pluginName string, bus *events.Bus) pubsdk.EventsAPI { - api := &eventsAPI{pluginName: pluginName, bus: bus, sec: security.RuntimeGlobal()} +func newEventsAPI(pluginName string, bus *events.Bus, rt *security.Runtime) pubsdk.EventsAPI { + // sec 由内核注入(不再读 RuntimeGlobal 包级单例)。 + api := &eventsAPI{pluginName: pluginName, bus: bus, sec: rt} if bus == nil { api.unavailable = "event bus is not wired in this host" } diff --git a/internal/sdk/events_test.go b/internal/sdk/events_test.go index 364aee85..7a488767 100644 --- a/internal/sdk/events_test.go +++ b/internal/sdk/events_test.go @@ -28,16 +28,14 @@ import ( func newTestEvents(t *testing.T, name string) (pubsdk.EventsAPI, *events.Bus) { t.Helper() bus := events.NewBus() - // 用当前全局 Runtime 造闸门(未装配时 NewRuntime(nil) 给 Disabled Runtime, - // 策略仍生效 —— 与生产同一条纪律:关闭凭据替换 ≠ 放开授权)。 - rt := security.RuntimeGlobal() - if rt == nil { - rt = security.NewRuntime(nil) - } + // ★ 2026-11-04:包级单例已删,Runtime 改为**显式构造并注入**。 + // NewRuntime(nil) 给 Disabled Runtime,策略仍生效 —— + // 与生产同一条纪律:关闭凭据替换 ≠ 放开授权。 + rt := security.NewRuntime(nil) if g := security.AsEventGuard(rt); g != nil { bus.SetGuard(g) } - return newEventsAPI(name, bus), bus + return newEventsAPI(name, bus, rt), bus } // ① 白名单事件可发布,且 Source 被强制设成插件名(不能冒名)。 @@ -130,7 +128,7 @@ func TestEventsSubscribeAllowlistedOnly(t *testing.T) { // ⑤ 载荷出向过安全闸:明文凭据被换成 @{别名},不会原样进事件。 func TestEventsPayloadIsScrubbed(t *testing.T) { dir := t.TempDir() - store, err := security.InitGlobal(security.Options{ + store, err := security.MustStore(security.Options{ Path: dir + "/vars.json", KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, Enabled: true, @@ -138,11 +136,15 @@ func TestEventsPayloadIsScrubbed(t *testing.T) { if err != nil { t.Fatal(err) } - t.Cleanup(func() { security.SetGlobal(nil) }) - security.SetRuntimeGlobal(security.NewRuntime(store)) - t.Cleanup(func() { security.SetRuntimeGlobal(nil) }) - - e, bus := newTestEvents(t, "leaky") + // ★ 2026-11-04:单例已删。这个用例要验证"出向载荷被闸门脱敏", + // 所以 Runtime 必须带 store;显式构造后注入给事件面。 + rt := security.NewRuntime(store) + bus0 := events.NewBus() + if g := security.AsEventGuard(rt); g != nil { + bus0.SetGuard(g) + } + e := newEventsAPI("leaky", bus0, rt) + bus := bus0 var payload map[string]interface{} bus.Subscribe(events.EventSkillDetected, func(evt *events.Event) { payload = evt.Payload }) diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index 5c983d44..3449f239 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -1,6 +1,7 @@ package sdk import ( + "github.com/JianFeeeee/HologramAgent/internal/security" "log" "sync" @@ -335,15 +336,25 @@ type AudioURL = pubsdk.AudioURL // SDKConfig holds all dependencies for creating a PluginSDK. type SDKConfig struct { - IOManager *agentIO.IOManager - EventBus *events.Bus - Memory MemoryAPI - TextMemory TextMemoryAPI - DocMemory DocMemoryAPI - Knowledge KnowledgeAPI - LLM LLMAPI - Settings SettingsAPI - Secrets pubsdk.SecretsAPI + IOManager *agentIO.IOManager + EventBus *events.Bus + // SecurityRuntime 是安全运行时(插件事件闸门 + 档位声明需要它)。 + // + // ★ 注入而不是让 sdk 读 security.RuntimeGlobal() 包级单例: + // 单例绕开了内核统一挂载点(kstate.State.Security),且 sdk 不能 + // import kstate(kstate 依赖 sdk,会成环)。 + // + // 本判据的由来(2026-11-04):原实现让 sdk 自己取单例,导致 + // "单例装的时机"与"插件加载的时机"隐式耦合 —— 装晚了插件就用默认 + // 最小策略,而日志里一个字都没有。 + SecurityRuntime *security.Runtime + Memory MemoryAPI + TextMemory TextMemoryAPI + DocMemory DocMemoryAPI + Knowledge KnowledgeAPI + LLM LLMAPI + Settings SettingsAPI + Secrets pubsdk.SecretsAPI // Security 是**策略声明面**(插件声明安全档位;判定由内核执行)。 // // ★ 只声明、不判定:本接口没有 CheckTool 之类的方法 —— 那属于内核。 @@ -411,7 +422,7 @@ func New(name string, cfg SDKConfig) *PluginSDK { iom: cfg.IOManager, eventBus: cfg.EventBus, - events: newEventsAPI(name, cfg.EventBus), + events: newEventsAPI(name, cfg.EventBus, cfg.SecurityRuntime), logger: log.Default(), pluginMgr: cfg.PluginMgr, diff --git a/internal/sdk/secrets_impl.go b/internal/sdk/secrets_impl.go index 5fca6138..709d3e4d 100644 --- a/internal/sdk/secrets_impl.go +++ b/internal/sdk/secrets_impl.go @@ -1,19 +1,25 @@ package sdk -// SecretsAPI 的内核实现:桥接到 `internal/security` 的进程级变量库。 +// SecretsAPI 的内核实现:桥接到注入的变量库(`*security.Store`)。 // // ## 为何是"桥接"而不是"另建一套" // -// 变量库是**进程级单例**(`security.Global()`):主/子/驻留子 agent 共享 -// 同一套别名表(设计 S10)。插件若自建一套,两库互不可见 —— -// 内核拦截时登记的变量插件看不到,插件登记的拦截又不认识, -// 同一个凭据可能两处各存一份而使用者不知哪个权威。 +// 变量库是**全进程唯一的一份**:主/子/驻留子 agent 共享同一套别名表 +// (设计 S10)。插件若自建一套,两库互不可见 —— 内核拦截时登记的变量 +// 插件看不到,插件登记的拦截又不认识,同一个凭据可能两处各存一份而 +// 使用者不知哪个权威。 // -// 所以这个实现只是把调用转发到那个单例。**没有第二份存储**。 +// 所以这个实现只是把调用转发给注入的 Store。**没有第二份存储**。 +// +// ## 注入而不是读单例(2026-11-04) +// +// 此前读 `security.Global()` 包级单例。那个单例绕开了内核的统一挂载点 +// (kstate.State.Security),已删除。现在由 plugin.Registry 从 +// ks.Security 取出 Store 传进来 —— 单一事实来源、单一注入路径。 // // ## 未启用时的行为 // -// `security.Global()` 为 nil 时**不返回 nil**,而是返回一个 Available() +// 注入的 Store 为 nil 时**不返回 nil API**,而是返回一个 Available() // 为 false 的实现。理由:nil 会让插件到处判空,而判空漏一处就 panic // (本仓在 `*SensitiveInterceptor` 上吃过这个亏)。给一个明确的 // "不可用" 实现,插件只需问 `Available()`。 @@ -26,12 +32,14 @@ import ( ) // secretsImpl 实现 pubsdk.SecretsAPI。 -type secretsImpl struct{} +type secretsImpl struct{ store *internalSecurity.Store } // NewSecretsAPI 构造变量库接口(内核装配时注入给每个插件)。 -func NewSecretsAPI() pubsdk.SecretsAPI { return &secretsImpl{} } +func NewSecretsAPI(store *internalSecurity.Store) pubsdk.SecretsAPI { + return &secretsImpl{store: store} +} -func (s *secretsImpl) Available() bool { return internalSecurity.Global() != nil } +func (s *secretsImpl) Available() bool { return s.store != nil } // AvailableForRPC 是 proc 桥用来**明确报告**可用性的入口。 // @@ -42,7 +50,7 @@ func (s *secretsImpl) Available() bool { return internalSecurity.Global() != nil func (s *secretsImpl) AvailableForRPC() bool { return s.Available() } func (s *secretsImpl) Store(value, note string) (string, error) { - st := internalSecurity.Global() + st := s.store if st == nil { // ★ 响亮失败:不静默降级成"存到别处"或"不存"。 // 插件以为存好了而实际没存,比直接报错危险得多。 @@ -52,7 +60,7 @@ func (s *secretsImpl) Store(value, note string) (string, error) { } func (s *secretsImpl) List() []pubsdk.SecretInfo { - st := internalSecurity.Global() + st := s.store if st == nil { return nil } @@ -68,7 +76,7 @@ func (s *secretsImpl) List() []pubsdk.SecretInfo { } func (s *secretsImpl) Forget(alias string) error { - st := internalSecurity.Global() + st := s.store if st == nil { return fmt.Errorf("安全模块未启用,变量库不可用") } @@ -76,7 +84,7 @@ func (s *secretsImpl) Forget(alias string) error { } func (s *secretsImpl) Fill(text string) (string, int, []string) { - st := internalSecurity.Global() + st := s.store if st == nil { return text, 0, nil } diff --git a/internal/sdk/security_impl.go b/internal/sdk/security_impl.go index d72d6465..e688e374 100644 --- a/internal/sdk/security_impl.go +++ b/internal/sdk/security_impl.go @@ -25,7 +25,7 @@ package sdk // // ## 未启用时的行为 // -// security 未装配(`RuntimeGlobal()` 为 nil)时返回明确错误,而不是静默成功。 +// security 未注入 Runtime 时返回明确错误,而不是静默成功。 // 理由:插件会以为自己的档位生效了,而实际什么都没登记 —— // 等到注入时带 `SecurityProfile: "qq.guest"` 才会响亮报"未声明档位", // 那时排查方向已经跑偏到"档位名拼错"。早失败比晚失败好。 @@ -42,18 +42,23 @@ import ( type securityImpl struct { // pluginName 是内核注入的**真实**插件名,不来自插件自述。 pluginName string + // rt 是内核注入的安全运行时(可 nil)。 + // + // ★ 2026-11-04:改用注入而不是 `RuntimeGlobal()` 包级单例 —— + // 那个单例绕开了内核统一挂载点(kstate.State.Security)。 + rt *internalSecurity.Runtime } // NewSecurityAPI 构造策略声明面(内核装配时注入给每个插件)。 // // pluginName 必须是内核侧的插件标识(Registry 里的 name), // **不能**取自插件自己的 Name() 返回值。 -func NewSecurityAPI(pluginName string) pubsdk.SecurityAPI { - return &securityImpl{pluginName: pluginName} +func NewSecurityAPI(pluginName string, rt *internalSecurity.Runtime) pubsdk.SecurityAPI { + return &securityImpl{pluginName: pluginName, rt: rt} } func (s *securityImpl) DeclareProfile(p pubsdk.SecurityProfile) error { - rt := internalSecurity.RuntimeGlobal() + rt := s.rt if rt == nil { return fmt.Errorf("security: declare profile %q failed - security module is not assembled "+ "(the profile would be silently unregistered)", p.Name) diff --git a/internal/sdk/security_impl_test.go b/internal/sdk/security_impl_test.go index 6fc51953..a0f529e3 100644 --- a/internal/sdk/security_impl_test.go +++ b/internal/sdk/security_impl_test.go @@ -20,20 +20,19 @@ import ( pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk" ) -// withRuntime 装一个干净的 Runtime 供测试,结束后恢复。 +// withRuntime 造一个干净的 Runtime 供测试。 +// +// ★ 2026-11-04:不再装包级单例(已删)。调用方必须把返回值**显式注入** +// 给被测面(NewSecurityAPI(name, rt)),这正是生产注入路径。 func withRuntime(t *testing.T) *internalSecurity.Runtime { t.Helper() - prev := internalSecurity.RuntimeGlobal() - rt := internalSecurity.NewRuntime(nil) - internalSecurity.SetRuntimeGlobal(rt) - t.Cleanup(func() { internalSecurity.SetRuntimeGlobal(prev) }) - return rt + return internalSecurity.NewRuntime(nil) } // ① 正常声明:登记到 Runtime,且 Owner 是内核给的名字。 func TestSecurityDeclareProfileRegistersUnderRealOwner(t *testing.T) { rt := withRuntime(t) - api := NewSecurityAPI("qq") + api := NewSecurityAPI("qq", rt) if err := api.DeclareProfile(pubsdk.SecurityProfile{ Name: "qq.guest", @@ -53,8 +52,8 @@ func TestSecurityDeclareProfileRegistersUnderRealOwner(t *testing.T) { // 若能声明 `qq.owner` 且给 AllowTools=["*"],等于把 QQ 的 owner 档位 // 改成全放行 —— 非所有者立刻能调用所有工具。 func TestSecurityDeclareProfileRejectsForeignOwner(t *testing.T) { - withRuntime(t) - api := NewSecurityAPI("evil") + rt := withRuntime(t) + api := NewSecurityAPI("evil", rt) err := api.DeclareProfile(pubsdk.SecurityProfile{ Name: "qq.owner", @@ -71,8 +70,8 @@ func TestSecurityDeclareProfileRejectsForeignOwner(t *testing.T) { // ③ 名字必须带本插件前缀(否则登记了也命中不到)。 func TestSecurityDeclareProfileRequiresOwnerPrefix(t *testing.T) { - withRuntime(t) - api := NewSecurityAPI("qq") + rt := withRuntime(t) + api := NewSecurityAPI("qq", rt) for _, bad := range []string{"guest", "qqguest", "qqq.guest", ""} { if err := api.DeclareProfile(pubsdk.SecurityProfile{Name: bad}); err == nil { @@ -86,11 +85,8 @@ func TestSecurityDeclareProfileRequiresOwnerPrefix(t *testing.T) { // 静默成功的后果:插件以为档位生效,直到注入时带 SecurityProfile // 才报"未声明档位",此时排查方向已跑到"名字拼错"上。 func TestSecurityDeclareProfileFailsLoudlyWhenUnassembled(t *testing.T) { - prev := internalSecurity.RuntimeGlobal() - internalSecurity.SetRuntimeGlobal(nil) - t.Cleanup(func() { internalSecurity.SetRuntimeGlobal(prev) }) - - api := NewSecurityAPI("qq") + // 未装配 = Runtime 为 nil(不注入),而不是"装了 nil 单例"。 + api := NewSecurityAPI("qq", nil) err := api.DeclareProfile(pubsdk.SecurityProfile{Name: "qq.guest"}) if err == nil { t.Fatal("★ 安全模块未装配时必须报错 —— 静默成功会让插件误以为策略已生效") @@ -102,8 +98,8 @@ func TestSecurityDeclareProfileFailsLoudlyWhenUnassembled(t *testing.T) { // ⑤ 批量声明:任一失败即整体报错,且错误带下标便于定位。 func TestSecurityDeclareProfilesReportsIndex(t *testing.T) { - withRuntime(t) - api := NewSecurityAPI("qq") + rt := withRuntime(t) + api := NewSecurityAPI("qq", rt) err := api.DeclareProfiles([]pubsdk.SecurityProfile{ {Name: "qq.owner"}, diff --git a/internal/security/gate_test.go b/internal/security/gate_test.go index ee551123..1d80aa23 100644 --- a/internal/security/gate_test.go +++ b/internal/security/gate_test.go @@ -19,7 +19,7 @@ import ( func testStore(t *testing.T) *Store { t.Helper() dir := t.TempDir() - s, err := InitGlobal(Options{ + s, err := MustStore(Options{ Path: dir + "/vars.json", KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, Enabled: true, @@ -28,7 +28,6 @@ func testStore(t *testing.T) *Store { t.Fatalf("InitGlobal: %v", err) } // 全局单例是进程级的:测试之间要复位,否则互相污染。 - t.Cleanup(func() { SetGlobal(nil) }) return s } diff --git a/internal/security/global.go b/internal/security/global.go index 49bc7874..6efd9ae8 100644 --- a/internal/security/global.go +++ b/internal/security/global.go @@ -18,84 +18,52 @@ package security //(`resident.go:185`;manager 全进程只建一份 `bootstrap.go:249`)。 // 所以变量库挂在 provider 层天然就是进程级 —— **不需要跨 agent 同步**。 -import ( - "log" - "sync" -) +import () -var ( - globalMu sync.RWMutex - global *Store -) +// ★ 2026-11-04:进程级单例 `global *Store` 已删除。 +// +// ## 为什么删 +// +// 它绕开了内核的**统一挂载点**(`kstate.State.Security`)。变量库只有一个 +// 事实来源:security 模块产出的 Runtime,经 Assembly 传进 +// `AgentConfig.Security` / `ProviderManager.SetSecretStore` / +// `plugin.Registry.SetSecretStore`,各消费方从自己的注入面取。 +// +// ## 单例的危害(不是"多一个全局变量"这么轻) +// +// 本 agent 的 `a.security` 与 `security.Global()` 是**两个独立变量**。 +// 正常装配下它们恰好指向同一份数据,但"恰好"不是保证: +// +// · 测试替身:测试装单例、被测代码用 agent 字段 ⇒ 测试测的是单例 +// · 多 Runtime:两个内核实例(或热重载)时单例只有一份,另一方被覆盖 +// · 装配顺序:单例装晚了,先注册的 provider 就没被装饰(静默漏一条出网路径) +// +// 最后一条曾真实存在:`ProviderManager.Register` 在注册时读单例套装饰器, +// 而 llm 模块原本**不声明** FacSecurity 依赖 —— 顺序正确纯属巧合。 +// 现在 llm 模块声明了 FacSecurity,且注入必须早于 Register。 -// InitGlobal 装配进程级单例。**必须在任何 agent 启动前调用**。 -func InitGlobal(opt Options) (*Store, error) { +// ---------------- 入向填充(插件无感知的关键) ---------------- + +// ★ 2026-11-04:`FillToolArgs` / `FillPayload` 已删除(死代码)。 +// +// 它们读 `Global()` 单例做入向回填,但**生产调用者为 0**: +// `core.StageHost.ExecuteToolCtx` 已直接用注入的 Store 做同一件事 +// (见 stages.go 的 `h.secretStore().FillMap(args)`)。 +// +// 只留一个测试调用它们 ⇒ 测的是没人用的代码路径,绿了也不代表任何 +// 生产行为正确(engineering-lessons §1.1「判据要钉真实路径」)。 + +// MustStore 构造并加载一个变量库(装配/测试用),失败直接返回 error。 +// +// ★ 存在的理由(2026-11-04):包级单例删除后,"New + Load" 这对操作在 +// 装配代码与十几个测试里重复出现。抽成函数让调用点保持一行, +// 也避免各人各写一遍(漏 Load 的症状是"库看起来在、但读不到已有条目")。 +// +// 注意:本函数**不**注册任何全局单例 —— 返回值由调用方持有并注入。 +func MustStore(opt Options) (*Store, error) { s := New(opt) if err := s.Load(); err != nil { return nil, err } - globalMu.Lock() - global = s - globalMu.Unlock() return s, nil } - -// SetGlobal 直接装入一个已构造的 Store(测试与特殊装配用)。 -func SetGlobal(s *Store) { - globalMu.Lock() - global = s - globalMu.Unlock() -} - -// Global 取进程级单例;未初始化时返回 nil。 -// -// ★ 为何返回 nil 而不报错:调用点散布在内核热路径上, -// 未启用时必须**零成本跳过**。所有调用方都写成 -// `if s := Global(); s != nil { ... }`。 -func Global() *Store { - globalMu.RLock() - defer globalMu.RUnlock() - return global -} - -// Enabled 报告安全模块是否已启用。 -func Enabled() bool { - s := Global() - return s != nil && s.enabled -} - -// ---------------- 入向填充(插件无感知的关键) ---------------- - -// FillToolArgs 把一个工具调用的参数里的 `@{别名}` 换回明文。 -// -// 这是**内核工具链的入口**(`core.StageHost.ExecuteTool`): -// 插件收到的参数已经填好,它完全不知道有替换这回事。 -// -// 返回 (填充处数, 用到的别名)。未初始化时返回 (0, nil)。 -func FillToolArgs(tool string, args map[string]interface{}) (int, []string) { - s := Global() - if s == nil || args == nil { - return 0, nil - } - n, used := s.FillMap(args) - if n > 0 { - log.Printf("[security] tool %s backfilled %d argument(s) (aliases %v)", tool, n, used) - } - return n, used -} - -// FillPayload 把一段文本载荷里的 `@{别名}` 换回明文。 -// -// 用于**设备输出通道**(`core` 的 `dev.Execute`):发给设备的指令同理 -// 需要真实凭据,而设备侧对替换无感知。 -func FillPayload(where, text string) string { - s := Global() - if s == nil || text == "" { - return text - } - out, n, used := s.Fill(text) - if n > 0 { - log.Printf("[security] %s payload backfilled %d place(s) (aliases %v)", where, n, used) - } - return out -} diff --git a/internal/security/runtime.go b/internal/security/runtime.go index 0547cd4c..936449c1 100644 --- a/internal/security/runtime.go +++ b/internal/security/runtime.go @@ -27,11 +27,6 @@ import ( "sync" ) -var ( - runtimeMu sync.RWMutex - runtime *Runtime -) - // Runtime 是安全运行时。 // // 它把安全模块的三块能力合成**一个边界**: @@ -85,6 +80,8 @@ type Runtime struct { scopeMu sync.Mutex } +// NewRuntime 用已装好的变量库构造运行时。 + // NewRuntime 用已装好的变量库构造运行时。 func NewRuntime(store *Store) *Runtime { if store == nil { @@ -117,23 +114,19 @@ func NewDisabledRuntime() *Runtime { } } -// RuntimeGlobal 取进程级运行时;未装配时返回 nil。 +// ★ 2026-11-04:进程级单例 `runtime *Runtime` 已删除(用户裁定 +// 「所有共享数据统一挂载一个 kernel 提供的挂载点上暴露」)。 // -// ★ 返回 nil 而不是 Disabled:这只表示"安全模块还没装配", +// 防火墙的产物现在是 `kstate.State.Security`:security 模块产出它, +// 消费方(agent / plugin-host / llm)各自从注入面取: // -// 装配完成后(无论启用与否)一定非 nil。调用方拿 nil 应视为装配错误。 -func RuntimeGlobal() *Runtime { - runtimeMu.RLock() - defer runtimeMu.RUnlock() - return runtime -} - -// SetRuntimeGlobal 装入运行时(幂等:重复装同一个实例无副作用)。 -func SetRuntimeGlobal(r *Runtime) { - runtimeMu.Lock() - runtime = r - runtimeMu.Unlock() -} +// AgentConfig.Security → a.security +// plugin.Registry.SetSecurityRuntime +// ProviderManager.SetSecretStore(变量库) +// +// 原先的 `RuntimeGlobal()` / `SetRuntimeGlobal()` 是**公开**访问器, +// 任何包都能绕开 ks 直接取 —— 那正是"两处各存一份、哪份权威取决于 +// 装配顺序"的来源。装配器内部的幂等缓存改为 kernelmod/security 私有变量。 // Enabled 报告安全审查是否真的生效。 func (r *Runtime) Enabled() bool { return r != nil && r.enabled && r.gate != nil } diff --git a/internal/security/singleton_test.go b/internal/security/singleton_test.go new file mode 100644 index 00000000..5cced37f --- /dev/null +++ b/internal/security/singleton_test.go @@ -0,0 +1,194 @@ +package security + +// 判据:**不存在包级单例**(2026-11-04 T3.1)。 +// +// ## 这些判据守的是什么 +// +// 用户裁定: +// +// > secutiry 的实现也是错误的,所有共享数据统一挂载一个 kernel 提供的 +// > 挂载点上暴露。 +// +// > store也应该调用security的加密能力! +// +// 本包原有**三份**独立包级单例: +// +// global *Store 变量库(凭据别名) +// runtime *Runtime 安全防火墙 +// globalCodec *Codec 配置落盘加密 +// +// 它们各自被 InitXxx / SetXxx 装入,各自有 Global() / RuntimeGlobal() / +// ConfigCodec() 访问器。三份都绕开了内核的统一挂载点 `kstate.State`。 +// +// ## 为什么单例是错的(不是"多一个全局变量"这么轻) +// +// 本 agent 的 `a.security` 与 `security.Global()` 是**两个独立变量**。 +// 正常装配下它们恰好指向同一份数据,但"恰好"不是保证: +// +// - 测试替身:测试装单例、被测代码用 agent 字段 ⇒ 测的是单例 +// - 多 Runtime:两个内核实例(或热重载)时单例只有一份,另一方被覆盖 +// - 装配顺序:单例装晚了,先注册的 provider 就没被装饰(静默漏一条出网路径) +// +// 最后一条**曾真实存在**:ProviderManager.Register 在注册时读单例套装饰器, +// 而 llm 模块原本不声明 FacSecurity 依赖 —— 顺序正确纯属巧合。 +// +// ## 判据为什么是源码扫描 +// +// 这是"不存在某物"的判据,没有可执行的行为可测。但**变异可验证**: +// 把 `var global *Store` 加回来 ⇒ 立刻红。 + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +// forbiddenPackageLevelVars 是**不得出现**的包级单例变量名。 +// +// 每一条都对应一次真实事故或一次可复现的错配: +// +// global → 变量库:测试装单例、代码用 agent 字段,测的与跑的不是同一份 +// runtime → 防火墙:多实例时被覆盖 +// +// ★ globalCodec 不在本表 —— 它属于 T3.2(Store 复用 Codec + Codec 归 Runtime)。 +// 那条改动还牵涉 config 的 6 个调用点与惰性密文迁移,单独一步做。 +// 本判据只钉已经完成的部分,避免"半成品状态让整套判据长期红" +// (长期红的判据会被人习惯性忽略,那时它就等于不存在)。 +func forbiddenPackageLevelVars() []string { + return []string{"global", "runtime"} +} + +// TestNoPackageLevelSingletons 钉住本包不得声明这三个包级变量。 +// +// 变红条件:把 `var global *Store`(或另外两个)加回来 ⇒ 红。 +func TestNoPackageLevelSingletons(t *testing.T) { + fset := token.NewFileSet() + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("读目录失败: %v", err) + } + + banned := make(map[string]bool, len(forbiddenPackageLevelVars())) + for _, n := range forbiddenPackageLevelVars() { + banned[n] = true + } + + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, filepath.Join(".", name), nil, 0) + if err != nil { + t.Fatalf("解析 %s 失败: %v", name, err) + } + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.VAR { + continue + } + for _, spec := range gd.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for _, ident := range vs.Names { + if banned[ident.Name] { + t.Fatalf("★ %s:%d 声明了包级单例 %q。\n\n"+ + "本包的单例已按用户裁定删除 —— 共享数据统一挂在\n"+ + "kstate.State 上,各消费方从自己的注入面取:\n\n"+ + "\tAgentConfig.Security → a.security.Store()\n"+ + "\tProviderManager.SetSecretStore\n"+ + "\tplugin.Registry.SetSecretStore\n\n"+ + "单例的问题不是'多一个全局变量',而是它与 a.security\n"+ + "是两个独立变量,正常装配下'恰好'同一份,但测试替身/\n"+ + "多 Runtime/装配顺序任一路径都可能分离。", + name, fset.Position(ident.Pos()).Line, ident.Name) + } + } + } + } + } +} + +// TestMustStoreDoesNotRegisterGlobally 钉住 MustStore 只是构造+加载。 +// +// 变异可验证:在 MustStore 里加一行 `global = s` ⇒ 上面的判据红 +// (因为 global 变量本身不存在,得先加变量)。 +// +// 本判据补的是另一半:**即使变量名换了**(比如叫 defaultStore), +// MustStore 也不得把结果存进任何包级位置。 +func TestMustStoreDoesNotRegisterGlobally(t *testing.T) { + dir := t.TempDir() + s1, err := MustStore(Options{ + Path: filepath.Join(dir, "a.json"), + KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, + Enabled: true, + }) + if err != nil { + t.Fatalf("MustStore: %v", err) + } + // 第二次调用必须得到**另一个**实例 —— 若内部有包级缓存, + // 第二次会返回同一个(那就是单例还活着)。 + s2, err := MustStore(Options{ + Path: filepath.Join(dir, "b.json"), + KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil }, + Enabled: true, + }) + if err != nil { + t.Fatalf("MustStore: %v", err) + } + if s1 == s2 { + t.Fatal("★ MustStore 两次返回同一实例 —— 说明它偷偷存了包级单例。\n" + + "它应当只做 New + Load,把实例交还调用方注入。") + } +} + +// TestStoreEncryptsWithOwnKey 钉住变量库确实是密文落盘(加密能力归属本模块)。 +// +// 这条是 T3.2(Store 复用 Codec)的前置事实:现在 Store 有自己的 seal/open。 +// T3.2 之后它会改为委托 Codec,本判据仍然成立(落盘必须是密文)。 +func TestStoreEncryptsWithOwnKey(t *testing.T) { + dir := t.TempDir() + const key = "0123456789abcdef0123456789abcdef" + path := filepath.Join(dir, "v.json") + + s, err := MustStore(Options{ + Path: path, + KeyFn: func() ([]byte, error) { return []byte(key), nil }, + Enabled: true, + }) + if err != nil { + t.Fatalf("MustStore: %v", err) + } + + const plain = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + if _, err := s.Submit(plain, "test"); err != nil { + t.Fatalf("Submit: %v", err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读落盘文件: %v", err) + } + if strings.Contains(string(raw), plain) { + t.Fatal("★ 变量库落盘里有明文 —— 加密没有生效") + } + + // 重新加载必须能解回来(密钥一致)。 + s2, err := MustStore(Options{ + Path: path, + KeyFn: func() ([]byte, error) { return []byte(key), nil }, + Enabled: true, + }) + if err != nil { + t.Fatalf("重新加载失败: %v", err) + } + if s2.Count() != 1 { + t.Fatalf("重新加载后应有 1 条,实际 %d", s2.Count()) + } +}