Files
HomeAgent/internal/security/profile.go
JianFeeeee 81a0f5f598 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/             → 干净
2026-11-03 09:51:11 +08:00

278 lines
9.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 security
// 插件**命名安全限式**(policy profile):插件声明风格,inputch 触发时选一个。
//
// ## 用户模型(2026-10-26)
//
// > 插件可以声明自身安全限制策略,并在插件自身 inputch 触发时携带参数
// > 选择其中一种 —— 这样正好对应 QQ 插件内部 owner 与非 owner 的差别对待。
//
// 于是"谁能做什么"变成**插件自己声明的命名档位**:
//
// qq 声明: "owner" → 允许开发类工具(cmd_* / files_write …)
// "guest" → 只允许聊天类工具
// "group" → 更窄(不得私发文件)
//
// 而每条 inputch 输入携带 `SecurityProfile: "qq.guest"`。
// 内核据此在该轮激活对应档位 —— **插件一行判定代码都不写**。
//
// ## 为什么档位必须由内核持有
//
// ① 插件崩了/被重载,档位不能跟着消失(否则挂掉插件即可绕过)
// ② 判定必须与全局策略、主体策略**同一条链**(否则又出现"两套语义")
// ③ 拒绝文案要统一(模型看到的拒绝语义必须一致,否则它会反复重试)
//
// ## 档位不是"提权"
//
// 档位只在**收窄**方向生效:它可以追加 deny、追加敏感词、收紧沙箱,
// 但**不能**绕过全局黑名单或全局白名单(判据见下)。
import (
"fmt"
"strings"
"sync"
"time"
)
// Profile 是插件声明的一个命名限式。
type Profile struct {
// Name 是**全局唯一**的档位名,约定 `<插件名>.<档位>`(如 "qq.owner")。
//
// ★ 强制前缀约定:拼错时能被立刻发现(`qq.ownr` 找不到 ⇒ 走默认档位
// 而不是静默放行)。
Name string
// Owner 是声明它的插件(审计用;跨插件覆盖会被拒绝)。
Owner string
// Policy 是这一档的能力集合。
Policy PrincipalPolicy
// SensitiveWords 是这一档追加的敏感词。
SensitiveWords []string
// ToolSensitiveWords 是**按工具**追加的敏感词(在 SensitiveWords 之上再追加)。
//
// key 是工具名模式(支持尾部 `*`),value 是该工具额外追加的词。
// 语义**追加不覆盖** —— 见 SDK 侧同名字段的说明。
ToolSensitiveWords map[string][]string
// DetectInjection 让这一档开启内置的**提示词注入迹象**识别。
//
// 与 SensitiveWords 的分工:敏感词是插件给的**数据**(它知道自己的
// 哪些词敏感),注入迹象是内核内置的**形态**规则(ignore previous /
// 角色劫持 / 索要系统提示 / 伪造更高优先级)。
//
// ★ 默认 false:失败即静默。注入识别会命中"扮演""你现在是"这类
// 正常对话词,对所有插件默认开启会让常识对话充满误报;
// 由插件按自己的内容形态显式声明。
DetectInjection bool
// DisableNetwork 在这一档内禁止网络意图。
DisableNetwork bool
// Sandbox 覆盖沙箱约束(零值 = 沿用全局)。
Sandbox SandboxPolicy
}
// profileRegistry 持有全部已声明档位(进程级,内核持有)。
type profileRegistry struct {
mu sync.RWMutex
byName map[string]Profile
}
func newProfileRegistry() *profileRegistry {
return &profileRegistry{byName: map[string]Profile{}}
}
// Declare 登记一个档位。
//
// 覆盖规则:**同一 Owner 可覆盖自己的档位**(便于插件更新),
// 但**不能覆盖别人的**(否则任何插件都能改写 QQ 的 owner 档 = 提权口子)。
func (r *profileRegistry) Declare(p Profile) error {
if r == nil {
return errNoRuntime
}
name := strings.TrimSpace(p.Name)
if name == "" {
return &policyErr{"security: profile name must not be empty"}
}
if p.Owner == "" {
return &policyErr{"security: profile " + name + " must declare Owner (audit + overwrite protection)"}
}
// 约定 `<owner>.<档位>`:档位名必须带自己的插件名前缀。
// 这样"跨插件覆盖"天然被名字挡住,而不是只靠下面的检查。
if !strings.HasPrefix(name, p.Owner+".") {
return &policyErr{fmt.Sprintf(
"security: profile %q 必须以声明者前缀 %q 开头(防止跨插件覆盖)", name, p.Owner+".")}
}
r.mu.Lock()
defer r.mu.Unlock()
if old, ok := r.byName[name]; ok && old.Owner != p.Owner {
return &policyErr{fmt.Sprintf(
"security: profile %q 已由 %q 声明,%q 不能覆盖", name, old.Owner, p.Owner)}
}
r.byName[name] = p
return nil
}
// Get 查询档位。
func (r *profileRegistry) Get(name string) (Profile, bool) {
if r == nil {
return Profile{}, false
}
r.mu.RLock()
defer r.mu.RUnlock()
p, ok := r.byName[name]
return p, ok
}
// Names 返回全部档位名(状态面用,排序后返回更稳定)。
func (r *profileRegistry) Names() []string {
if r == nil {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.byName))
for n := range r.byName {
out = append(out, n)
}
return out
}
// activeProfile 是一次已激活的档位(带 TTL)。
type activeProfile struct {
name string
expires time.Time
}
// profileActivator 持有"当前活跃档位"。
//
// ★ 为什么是"当前活跃"而不是"每次调用都传":
// 工具调用发生在 LLM 决定之后,此时插件已经不在调用栈上(异步通道、
// 多轮工具循环)。唯一可靠的传递方式是**内核在输入进来时记住**,
// 在该轮的工具判定里使用。
type profileActivator struct {
mu sync.RWMutex
active activeProfile
}
// Activate 激活一个档位(带 TTL)。
func (a *profileActivator) Activate(name string, ttl time.Duration) {
if a == nil || name == "" {
return
}
if ttl <= 0 {
ttl = scopeTTLDefault
}
a.mu.Lock()
a.active = activeProfile{name: name, expires: time.Now().Add(ttl)}
a.mu.Unlock()
}
// Current 返回当前活跃档位名(过期即空)。
func (a *profileActivator) Current() string {
if a == nil {
return ""
}
a.mu.RLock()
cur := a.active
a.mu.RUnlock()
if cur.name == "" || time.Now().After(cur.expires) {
return ""
}
return cur.name
}
// Clear 清除活跃档位(一轮结束/换来源时调用)。
func (a *profileActivator) Clear() {
if a == nil {
return
}
a.mu.Lock()
a.active = activeProfile{}
a.mu.Unlock()
}
// ───────────────────── Runtime 上的对外入口 ─────────────────────
// DeclareProfile 是**插件声明限式**的入口(SDK 转发到这里)。
func (r *Runtime) DeclareProfile(p Profile) error {
if r == nil || r.profiles == nil {
return errNoRuntime
}
return r.profiles.Declare(p)
}
// Profiles 返回已声明档位名(诊断/状态面用)。
func (r *Runtime) Profiles() []string {
if r == nil || r.profiles == nil {
return nil
}
return r.profiles.Names()
}
// ActivateProfile 在 inputch 输入进来时激活插件选择的档位。
//
// 调用点:注入层拿到 `InjectOptions.SecurityProfile` 之后。
// ★ 未声明的档位**不静默放行**:返回错误,让注入方能看见配置错了
// (拼错档位名却默默用默认档,是安全事故的经典形态)。
func (r *Runtime) ActivateProfile(name string, ttl time.Duration) error {
if r == nil || r.profiles == nil {
return errNoRuntime
}
if name == "" {
// 空 = 不使用档位(沿用全局策略)。这是**显式**选择,不是拼错。
if r.active != nil {
r.active.Clear()
}
return nil
}
if _, ok := r.profiles.Get(name); !ok {
return &policyErr{fmt.Sprintf(
"security: 未声明的安全档位 %q(已声明: %v)—— 拼错不会退化,直接报错", name, r.profiles.Names())}
}
if r.active == nil {
return errNoRuntime
}
r.active.Activate(name, ttl)
return nil
}
// ActiveProfile 返回当前活跃档位名(空 = 未使用档位)。
func (r *Runtime) ActiveProfile() string {
if r == nil {
return ""
}
return r.active.Current()
}
// ErrNoRuntimeForBridge 表示桥接层拿到 nil Runtime(装配顺序错误)。
var ErrNoRuntimeForBridge = &policyErr{"security: security runtime is not initialized"}
// TTLSeconds 把秒数转成 Duration(0 = 用默认 TTL)。
//
// 为什么桥接层用秒而不是 Duration:SDK 是跨语言的(Lua/Go/外部进程),
// 秒是唯一无歧义的单位。
func TTLSeconds(n int) time.Duration {
if n <= 0 {
return 0
}
return time.Duration(n) * time.Second
}
// profilePolicy 取当前活跃档位的策略(无档位时返回零值 + false)。
func (r *Runtime) profilePolicy() (Profile, bool) {
return r.profilePolicyByName(r.ActiveProfile())
}
// profilePolicyByName 按**指定**档位名取策略(per-agent 视图用)。
//
// ★ 与 profilePolicy 的分工:
//
// profilePolicy —— 读进程级 active(旧路径,单执行体场景)
// profilePolicyByName —— 由调用方给出档位(per-agent 视图必须走这条)
//
// 之所以要拆:per-agent 视图的档位是**它自己的** session/longtime,
// 不能去读 `Runtime.active` —— 那样多 agent 并发时仍会串档。
func (r *Runtime) profilePolicyByName(name string) (Profile, bool) {
if name == "" || r == nil || r.profiles == nil {
return Profile{}, false
}
return r.profiles.Get(name)
}