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:
JianFeeeee
2026-11-04 11:57:48 +08:00
parent cc0eb51286
commit 47afd67ca1
44 changed files with 819 additions and 322 deletions

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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 里)。

View File

@ -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 接管就不再进本内核队列。

View File

@ -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:认领待登记的落盘记录

View File

@ -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)
}

View File

@ -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, "测")

View File

@ -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, "测试")

View File

@ -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"

View File

@ -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(),

View File

@ -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: "故意回吐明文",

View File

@ -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
}

View File

@ -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 时不该返回空")
}

View File

@ -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
}

View File

@ -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)

View File

@ -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 同源
})
// 故意传**未经过任何上游清洗**的原始值

View File

@ -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() })

View File

@ -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),变量库不可用"
}

View File

@ -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() {

View File

@ -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++ {

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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 + 错误**值**返回的,所以判据必须看返回值。

View File

@ -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 {

View File

@ -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)
}

View File

@ -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()...)
}

View File

@ -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)

View File

@ -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},
}

View File

@ -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) {

View File

@ -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 真正需要的只读工具视图)。
//

View File

@ -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)。
//

View File

@ -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)

View File

@ -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)

View File

@ -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 会明确报错 ——
// 那是对的:它们本来就没有随包资源。

View File

@ -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"
}

View File

@ -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 })

View File

@ -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,

View File

@ -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
}

View File

@ -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)

View File

@ -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"},

View File

@ -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
}

View File

@ -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
}

View File

@ -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 }

View File

@ -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())
}
}