feat(security): 插件按工具声明内容策略 + 目录名统一

## 一、插件声明层(用户裁定)

> 「敏感词策略也应该是插件声明啊,每个插件声明自己轮次以及自己工具的
>   敏感词策略」

SecurityProfile 新增两个字段:

  ToolSensitiveWords map[string][]string  // key=工具名模式(尾 *),value=词表
  DetectInjection    bool                 // 开启内核内置注入迹象识别

语义是**追加不覆盖**:整轮 SensitiveWords 仍然生效。
取宽不取窄的理由:当前处置是 B 档(标记+警示,不阻断),
多标一条只是多一句警示,漏标则是安全缺口。

## 二、修掉我自己造的「莲藕式分层」

前一版把 *security.Round 从 runInputTask 一路搬到工具链
(prepareInputTask(evt, round) → TaskFrame.SecRound →
executeToolCallOutcomeWithRound),并在 rtchild 里另补一条。

那是把**分层策略实现成跨层传参**:每多一条工具执行路径就要求调用方
多传一次句柄。profileActivator 的设计注释早就写明了正路:

> 工具调用发生在 LLM 决定之后,此时插件已不在调用栈上。
> 唯一可靠的传递方式是**内核在输入进来时记住**,在该轮的工具判定里使用。

改为 security 自取:

  Runtime.ScanContentForAgent(agentID, isChild, ins, text)
    → ForAgent(agentID, isChild)   // per-agent 视图,天然不串档
    → EffectiveProfile()           // session > longtime
    → 合并 全局词表 + 档位词表 + 工具词表 + DetectInjection

core 只递交**事实**(我是谁 / 哪个工具 / 什么文本),不认识 Round。
与 CheckToolForAgent 同一套身份推断,不引入第二个入口语义。
TaskFrame.SecRound / executeToolCallOutcomeWithRound 全部撤除。

## 三、目录名统一

third_party/homeagent-sdk → third_party/hologramagentsdk

module 与远端仓早已是 hologramagentsdk,只剩物理目录是遗留名。
同步改了 go.mod replace、构建/打包/部署脚本、5 处测试里的分段路径。
注意:这里改的是**主仓 vendored 的 SDK 源码路径**,example/ 由 SDK
独立仓自己跟踪,主仓不涉及。

## 判据

TestToolSensitiveWordsAppendOnlyForThatTool   词表只对目标工具生效 + 追加语义
TestToolSensitiveWordsUseAgentScope           security 按 agent scope 自取生效
(变异验证:删掉 ForAgent 查询 → 红)

## 验证

	go build ./...                 → 0
	go vet ./...                   → 0
	go test ./... -count=1 -race   → 62 ok / 0 FAIL / 0 DATA RACE
	gofmt -l internal/             → 干净
This commit is contained in:
JianFeeeee
2026-11-03 09:51:11 +08:00
parent 9ab19f7493
commit 81a0f5f598
53 changed files with 201 additions and 78 deletions

View File

@ -0,0 +1,184 @@
package sdk
import (
"os"
"strings"
"testing"
)
func TestProxyAuthDefaultsToHologramAgent(t *testing.T) {
// 空串必须归一化为「HologramAgent 统一保护」——这是安全默认。
// 若哪天有人把默认改成 none,这条会立刻红。
if got := EffectiveProxyAuth(""); got != ProxyAuthAgent {
t.Fatalf("空 auth 应归一化为 %q,实际 %q", ProxyAuthAgent, got)
}
if got := EffectiveProxyAuth(ProxyAuthNone); got != ProxyAuthNone {
t.Fatalf("显式 none 应保持 none,实际 %q", got)
}
for _, ok := range []string{"", ProxyAuthAgent, ProxyAuthNone} {
if !ValidProxyAuth(ok) {
t.Errorf("%q 应合法", ok)
}
}
for _, bad := range []string{"nope", "HOMEAGENT", "None", "true"} {
if ValidProxyAuth(bad) {
t.Errorf("%q 应非法", bad)
}
}
}
// 存量 plugin.json 里写的是旧值 "homeagent"(本机实测 huawei_smarthome)。
// 改名后必须**继续接受并归一化**它 —— 否则那些插件在加载时被判为非法声明,
// 反代直接不生效,症状是"插件好好的但页面打不开",且没有任何报错指向改名。
func TestProxyAuthLegacyValueStillAccepted(t *testing.T) {
if !ValidProxyAuth(ProxyAuthAgentLegacy) {
t.Fatalf("旧值 %q 必须仍被接受", ProxyAuthAgentLegacy)
}
if ProxyAuthAgentLegacy == ProxyAuthAgent {
t.Fatal("旧值应与新值不同,否则本判据测不到东西")
}
// ★ 归一化是最关键的一步:下游一律只可能拿到新值,
// 否则每个判断点都要写两遍比较,漏一处就是鉴权绕过。
if got := EffectiveProxyAuth(ProxyAuthAgentLegacy); got != ProxyAuthAgent {
t.Fatalf("旧值应归一化为 %q,实际 %q", ProxyAuthAgent, got)
}
// 存量 plugin.json 的字面量必须能过校验(这是真实数据,不是假设)。
if !ValidProxyAuth("homeagent") {
t.Fatal(`存量 plugin.json 里的 "auth": "homeagent" 必须仍被接受`)
}
}
func TestValidProxyHostLabel(t *testing.T) {
legit := []string{"huawei", "a", "a-b", "abc123", "0", "x" + string(make([]byte, 0)) + "yz"}
for _, s := range legit {
if !ValidProxyHostLabel(s) {
t.Errorf("%q 应为合法 label", s)
}
}
bad := []string{
"", "-a", "a-", "-", "a_b", "a.b", "A", "aB", "a b",
"a/b", "a:b", string(make([]byte, 64)), // 超长 63
}
for _, s := range bad {
if ValidProxyHostLabel(s) {
t.Errorf("%q 应为非法 label", s)
}
}
// 边界:恰好 63 合法,64 非法
l63 := ""
for i := 0; i < 63; i++ {
l63 += "a"
}
if !ValidProxyHostLabel(l63) {
t.Error("63 字符应为合法 label")
}
if ValidProxyHostLabel(l63 + "a") {
t.Error("64 字符应为非法 label")
}
}
func TestNormalizeProxyHost(t *testing.T) {
cases := map[string]string{
"huawei_smarthome": "huawei-smarthome", // 下划线不是合法 DNS label
"webui": "webui",
"UPPER_Case": "upper-case",
"a__b": "a--b",
"__x__": "x",
"---": "plugin", // 全非法 → 保守回退
"": "plugin",
"a.b.c": "abc",
}
for in, want := range cases {
if got := NormalizeProxyHost(in); got != want {
t.Errorf("NormalizeProxyHost(%q) = %q,期望 %q", in, got, want)
}
}
// 归一化结果必须自身合法(产物自洽)
for _, in := range []string{"huawei_smarthome", "UPPER_Case", "__x__", "a.b.c", "非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字"} {
if got := NormalizeProxyHost(in); !ValidProxyHostLabel(got) {
t.Errorf("NormalizeProxyHost(%q) = %q 不合法", in, got)
}
}
}
func TestValidateProxyDef(t *testing.T) {
valid := []ProxyDef{
{Target: "127.0.0.1:12100"},
{Target: "http://127.0.0.1:12100"},
{Target: "127.0.0.1:12100", Host: "huawei"},
{Target: "127.0.0.1:12100", Auth: ProxyAuthNone},
{Target: "127.0.0.1:12100", Auth: ProxyAuthAgent, WebSocket: true},
{Target: "127.0.0.1:3000/base", Host: "x"},
{Target: "https://example.com", Host: "ext"}, // 远程上游也允许(由 auth 决定安全性)
}
for _, d := range valid {
if msg := ValidateProxyDef(d); msg != "" {
t.Errorf("%+v 应合法,却报: %s", d, msg)
}
}
bad := []ProxyDef{
{}, // 无 target
{Target: " "}, // 空白 target
{Target: "127.0.0.1:12100", Auth: "yes"}, // auth 非法
{Target: "127.0.0.1:12100", Host: "a_b"}, // host 非法
{Target: "127.0.0.1:12100", Host: "-x"},
{Target: "127.0.0.1:12100", Host: "X"},
{Target: "://12100"}, // 无主机
{Target: "http:///path"}, // 无主机
{Target: "127.0.0.1:notaport"}, // 端口非数字
}
for _, d := range bad {
if msg := ValidateProxyDef(d); msg == "" {
t.Errorf("%+v 应被拒绝,却通过了", d)
}
}
}
// ---- 单一入口原则 ----
// 被反代的插件必须能同时适配 Host 形态与 Path 形态。这两条判据把
// 「插件内部不得用根绝对路径」这条契约钉在**可执行**的层面:
// 声明合法不代表它的资源能被两种形态访问到 —— 后者取决于插件前端的写法,
// 而 SDK 只能把要求写清楚并给出校验工具。
func TestSingleEntryPrincipleDocumented(t *testing.T) {
// Path 形态下插件前端必须用相对路径,否则请求会打到门户自己。
// 这是**文档级约定**,只能靠 review 与这份判据共同保证:
// 判据确保 SDK 里确实写明了这条要求(防止后来者删掉注释)。
src, err := os.ReadFile("proxy.go")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"单一入口原则",
"相对路径",
"根绝对路径",
} {
if !strings.Contains(string(src), want) {
t.Errorf("SDK 文档缺少「%s」—— 单一入口原则是反代的硬要求,不能只存在于口头约定里", want)
}
}
}
// strip_path 的两种语义必须由声明者显式选,且非法组合要被挡住。
func TestStripPathValidation(t *testing.T) {
// 合法:两种模式
for _, d := range []ProxyDef{
{Target: "127.0.0.1:1", Path: "/p/app", StripPath: true},
{Target: "127.0.0.1:1", Path: "/api/v1/device", StripPath: false},
} {
if msg := ValidateProxyDef(d); msg != "" {
t.Errorf("应合法却被拒: %+v → %s", d, msg)
}
}
// 非法:strip_path 但没有 path(没有可剥的前缀)
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", StripPath: true}); msg == "" {
t.Error("strip_path=true 而无 path 应被拒(没有可剥的前缀)")
}
// 非法:前缀模式挂到根会吞掉整个门户。
// 实际由「不应以 / 结尾」规则挡下("/" 同时是前缀又以 / 结尾),
// 这里断言的是**行为**:这种声明无论如何都不能通过。
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", Path: "/", StripPath: true}); msg == "" {
t.Error("path=\"/\" + strip_path 应被拒(会覆盖整个门户)")
}
}