Files
HomeAgent/internal/agent/api/security_wiring_test.go
JianFeeeee 55fa2685ae feat(security,device): security 成为独立内核模块(闸门+策略+限式),device 接管内置工具路由
security(新模块 internal/kernelmod/security)
- 凭据闸门:出向拦截成 @{别名}、入向清洗、执行前回填(internal/security/{gate,runtime}.go)
- 工具黑白名单 + 沙箱(读写根、网络、.. 逃逸):internal/security/policy.go
- 敏感词过滤 + 提示词注入预警(Finding 不带原文):internal/security/content.go
- 按主体授权(hard-private 对非内部主体永久拒绝):internal/security/principal.go
- 插件命名限式(可选档位,插件声明/内核执行,只能收窄):internal/security/profile.go
- 启动装配从 cmd/homed 迁入模块;启动顺序判据改盯真实装配点

device(新模块)
- 正式内核模块,Provides FacDevice,KernelState.Device
- 内置工具路由表下沉到 internal/toolroute(避免 core↔kernelboot 环),
  device 保留对外入口;agent 未挂载时退化本地 switch
- Agent 侧只留 api.ToolHost/DeviceSurface 适配层

SDK
- InjectOptions.SecurityProfile:插件按 inputch 声明本轮安全档位(可选)
- InjectOptions.TurnHint:插件注入**本轮事实**,内核以独立 system 消息注入,
  不混进用户正文(人设负责怎么说话,TurnHint 只放这一轮成立的事实)
- FacilitySecurity / FacilityDevice

提示词
- 内核自带 prompt/skill/工具返回全面英文化(中文提示会让模型行为漂移)
- 机制解释下沉到 4 个内置 skill(memory/output/tool-order/plugins),
  skill 描述压到 80 runes 内(原来 94–108 全被索引截断)
- 默认人格模板英文化;[Voice] 段删除,规则并入 persona 块(单一段落)
- 历史记忆工具改名 coldcontext_query/commit,图记忆语义改称 stable memory

修复
- tracker: exec mount → unix.Mount(降权运行 + AmbientCapabilities 下才能挂)、
  区分 EPERM 与不支持;启动日志说明后果与恢复方式
- healthcheck_kernel 输出瘦身:status.Tools 只给摘要(原 98,989 tokens/151% 预算)
- plugin_list 描述讲清 auto_start(按需)与 alive(此刻是否在跑),
  明确不能由 alive=false 推断能力不存在
- pluginmgr/webui 去掉写死的 /home/newqqagent 回退路径(env → pluginDir 反推 → 报错)
- lua: 盘上适配器与内置版本不一致且无基线时响亮告警(deepseek 流式丢失
  tool_calls 导致'模型不调工具'的真因)

测试:go build ./... 通过;go test ./... 60 包全绿
2026-10-26 20:55:25 +08:00

209 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
// 装配顺序判据。
//
// ## 为什么这个顺序值得单独立判据
//
// 安全模块的初始化必须在**任何 provider 注册之前**。装晚了的后果是:
// 先注册的那批 provider 没被装饰 ⇒ **静默漏掉一条出网路径**,
// 而且没有任何报错、日志也正常 —— 属于最难发现的那类失效。
//
// 而这个坑在本仓是真实存在的:
// · `cmd/homed/main.go` 的 initLLMProviders(主路径)
// · `internal/sdk/llm_impl.go` 的 ReloadFromConfig(配置热重载)
// · `cmd/homed/failback.go` 的 runFailbackRecovery(降级救援)
//
// 三个地方都会 Register。前两个在 security 之后;failback 也必须之后
// (main.go 里它是第 174 行,安全模块是 162 行)。
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/security"
)
// ★★ 判据:**晚注册的 provider 也自动被装饰**(因为装饰挂在 Register 上)。
//
// 这条钉住的是"挂载点选择正确":如果装饰挂在某个初始化函数里
// (而不是 Register),那么后注册的 provider 就会漏掉。
func TestLateRegistrationStillDecorated(t *testing.T) {
dir := t.TempDir()
store, err := security.InitGlobal(security.Options{
Path: filepath.Join(dir, "v.json"),
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
Enabled: true,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { security.SetGlobal(nil) })
const tok = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
m := NewProviderManager()
// ① 先注册一个
var first string
m.Register("early", &recordingProvider{name: "early", capture: &first})
// ② 模拟"热重载":Reset 后注册全新一批(ReloadFromConfig 的真实流程)
m.Reset()
var second string
m.Register("late", &recordingProvider{name: "late", capture: &second})
// ③ 再模拟 failback 的 rescue provider
var third string
m.Register("rescue", &recordingProvider{name: "rescue", capture: &third})
// 三个都必须被装饰
for _, tc := range []struct {
name string
got *string
}{{"late", &second}, {"rescue", &third}} {
if _, err := m.Get(tc.name).Chat(context.Background(),
&CompletionRequest{Messages: []Message{{Role: "user", Content: "t=" + tok}}}); err != nil {
t.Fatal(err)
}
if strings.Contains(*tc.got, tok) {
t.Errorf("provider %s 未被装饰(明文泄漏): %s", tc.name, *tc.got)
}
}
_ = first
if store.Count() == 0 {
t.Error("应登记变量")
}
}
// ★ 判据:**已装饰的不再包第二层**(避免热路径白跑)。
func TestNoDoubleWrapping(t *testing.T) {
dir := t.TempDir()
if _, err := security.InitGlobal(security.Options{
Path: filepath.Join(dir, "v.json"),
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
Enabled: true,
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { security.SetGlobal(nil) })
m := NewProviderManager()
base := &recordingProvider{name: "x", capture: new(string)}
m.Register("a", base)
inner := m.Get("a")
// 把已装饰的实例再注册一次(某些装配路径会这么干)
m.Register("b", inner)
if _, isDecorator := m.Get("b").(*secureProviderDecorator); !isDecorator {
t.Fatal("应当仍是装饰实例")
}
// 再包一层的话,类型仍是装饰器但会嵌套 —— 用指针比较确认是同一个
if m.Get("b") != inner {
t.Error("已装饰的实例不应被再包一层(会白跑一遍正则扫描)")
}
}
// ★★ 判据:**main.go 里安全模块的初始化必须早于 initLLMProviders**。
//
// 这是源码级判据(读文件而不是跑逻辑),因为顺序错误在单测里
// 构造不出来(main 的装配流程没法在测试里复现)。
// 源码判据在此是有意为之:它盯的是一个**真实的、曾经坑过的**装配顺序。
func TestMainInitsSecurityBeforeProviders(t *testing.T) {
src, err := os.ReadFile("../../../cmd/homed/main.go")
if err != nil {
t.Skipf("读不到 main.go(相对路径变了?): %v", err)
}
text := string(src)
// ★ 2026-10-26:安全装配搬进正式内核模块 internal/kernelmod/security。
// main.go 里的真实装配点是 `securitymod.Prepare(...)`,而
// `security.InitGlobal` 在模块实现里。判据必须同时盯住**两个真实点**:
// · main.go 的 Prepare 调用(装配时机)
// · 模块实现里的 InitGlobal(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)
}
// ★ 锚点已随"内核模块化"改写(2026-10-16)。
//
// 原先锚 `initLLMProviders(cfg,`(那时它直接写在 main 里)。
// 模块化后 **provider 注册搬进了 llm 模块**(`internal/kernelmod/llm`),
// 它在 `RunKernelModules` 内部执行 ⇒ 旧的字符串在 main.go 里**已经不存在**,
// 判据因"找不到"而失败(假红)。
//
// **注意:这不是放宽判据,而是把锚点换到新的真实装配点。**
// 不变量本身没变:安全模块必须早于**任何 provider 注册**。
// 新的等价表述:安全模块早于 `RunKernelModules`(llm 模块在它内部)。
//
// 为防止"换个锚点就变绿"式的偷懒,另加两条硬校验(见下)。
iProv := strings.Index(text, "RunKernelModules(")
if iSec < 0 {
t.Fatal("main.go 里找不到 securitymod.Prepare / security.InitGlobal —— 安全模块根本没被装配")
}
if iProv < 0 {
t.Fatal("main.go 里找不到 RunKernelModules —— 内核模块装配点没了")
}
if iSec > iProv {
t.Fatalf("装配顺序错误:security.InitGlobal 在第 %d 字节,RunKernelModules 在第 %d 字节\n"+
"必须先初始化安全模块 —— 内核模块装配时会注册 LLM provider,"+
"晚于它则 provider 不会被装饰(静默漏掉一条出网路径)",
iSec, iProv)
}
// ★ 加固 ①:provider 注册**确实**在内核模块里(否则新锚点就是空的 ——
// 那等于把判据锚在一个与 provider 无关的位置上)。
// 判据:kernelmod/llm 模块必须注册,且其 Init 里出现 initLLMProviders。
llmSrc, lerr := os.ReadFile("../../kernelmod/llm/mod.go")
if lerr != nil {
t.Fatalf("读不到 llm 模块(provider 注册应在这里): %v", lerr)
}
if !strings.Contains(string(llmSrc), "initLLMProviders") {
t.Fatal("★ llm 模块里没有 initLLMProviders —— " +
"那么上面锚 RunKernelModules 就与 provider 注册无关了(判据会变空)")
}
// ★ 加固 ②:llm 模块必须是**真注册过**的(不是死代码)
allSrc, aerr := os.ReadFile("../../kernelmod/all/all.go")
if aerr != nil {
t.Fatalf("读不到模块总表: %v", aerr)
}
if !strings.Contains(string(allSrc), "kernelmod/llm") {
t.Fatal("★ llm 模块没被 import ⇒ 它的 init() 不跑 ⇒ provider 永不注册")
}
// failback 也要在之后
iFail := strings.Index(text, "runFailbackRecovery")
if iFail >= 0 && iSec > iFail {
t.Error("runFailbackRecovery 在安全模块之前 —— rescue provider 会漏装饰")
}
}
// ★ 判据:阶段断言 —— 源码里装饰发生在 Register 内部(挂载点正确)。
func TestDecorateHappensInsideRegister(t *testing.T) {
src, err := os.ReadFile("provider.go")
if err != nil {
t.Fatal(err)
}
text := string(src)
// Register 函数体内必须出现 WrapProviderForSecurity
i := strings.Index(text, "func (m *ProviderManager) Register(")
if i < 0 {
t.Fatal("找不到 Register")
}
// 取到下一个顶层 func 为止
rest := text[i:]
if j := strings.Index(rest[1:], "\nfunc "); j > 0 {
rest = rest[:j+1]
}
if !strings.Contains(rest, "WrapProviderForSecurity") {
t.Fatal("Register 函数体内没有装饰调用 —— 装饰被挪走了(后注册的 provider 会漏掉)")
}
}