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