mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 一、插件声明层(用户裁定)
> 「敏感词策略也应该是插件声明啊,每个插件声明自己轮次以及自己工具的
> 敏感词策略」
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/ → 干净
278 lines
9.1 KiB
Go
278 lines
9.1 KiB
Go
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)
|
||
}
|