Files
HomeAgent/internal/security/profile.go
JianFeeeee 82932821c0 feat(security): 每 agent 一个策略实例 —— sessionProfile / longtimeProfile 两层
## 用户裁定

> 每个 agent 一个,分为 sessionProfile 和 longtimeProfile

## 收敛前的缺陷

档位挂在 `Runtime.active`(**进程级单例**)上,多 agent 并发时串档:

	主 agent 轮次 A:注入 qq.guest  →  active = "qq.guest"
	驻留子 轮次 B:注入 qq.owner    →  active = "qq.owner"     ← 覆盖 A
	主 agent 轮次 A 继续执行工具     →  判定按 owner 走        ← 串档

这不是理论风险:驻留子 / rtchild 与主 agent **同进程并发**(plan.md 十一)。

## 切分原则:共享注册表 + per-agent 执行视图

**不能整份复制 Runtime** —— `profiles` / `principals` / `scopes` 是插件
声明出来的注册表,每个 agent 各持一份就会变成「插件往 A 声明了档位,
B 看不见」,那是最难查的一类缺陷。

	共享(进程级 1 份)              per-agent(N 份)
	────────────────────────        ───────────────────
	gate / policy / content          sessionProfile   ← 轮次级
	profiles / principals            longtimeProfile  ← agent 生命期
	scopes / eventPolicy

## 两层档位语义(用户裁定)

	sessionProfile  —— 属于**这一轮**。输入携带它,End() 即清。
	                   严格 per-round:同一用户下一条消息要重新声明。
	longtimeProfile —— 属于**这个 agent 的一段生命**。设置后跨轮保留,
	                   直到显式改掉或 agent 销毁。

**优先级 session > longtime**,这个顺序本身是安全语义:

	session 是"这一轮的事实",必须能盖住 long-time 默认值 ——
	否则访客发一条消息也无法在长期为 owner 的 agent 上收窄权限。

	反向(longtime 盖 session)= 提权:长期 owner 的 agent 上,
	访客声明的 guest 被吞掉 ⇒ 访客拿到 owner 权限。

## 改动

① 新增 `internal/security/agentscope.go`:

	rt.ForAgent(agentID, isChild) *AgentScope   // 幂等
	sc.BeginRound(RoundInput)                   // security 自己解析档位
	sc.SetLongtimeProfile(name) / LongtimeProfile()
	sc.EffectiveProfile()                        // session > longtime
	sc.CheckTool(tool)                           // 全局基线 + 档位 + 主体策略
	rt.ReleaseAgent(agentID)                     // 含 longtime 档一起清

② `Runtime` 新增 `scopesByAgent` 表;判定拆出 `checkToolWithProfile(p, profile, tool)`
   与 `checkToolForWithProfile(...)` —— **档位由调用方显式传入**,
   不再一律读全局 `r.active`(否则 per-agent 视图白做)。

③ `agent/core`:`securityScope()` 用 `sync.Once` 缓存本 agent 的视图;
   `Stop()` 里 `ReleaseSecurityScope()`。

④ `Round.End()`:per-agent 路径只清 **session**,**保留** longtime;
   旧进程级路径仍恢复进入前的值。

## 判据(3 个变异全部实测咬住)

| 判据 | 变异 |
|---|---|
| `TestAgentScopesDoNotShareSessionProfile` | ForAgent 退化为共享视图 ⇒ 红 |
| `TestLongtimeProfileSurvivesRounds` | 优先级写反 / longtime 不跨轮 ⇒ 红 |
| `TestReleaseAgentDropsLongtimeProfile` | ReleaseAgent 不删视图 ⇒ 红 |
| `TestLongtimeProfileRejectsUndeclared` | 未声明档位被接受 ⇒ 红 |

第一条同时钉:① 各自档位是自己的 ② **行为上**主 agent 仍被 guest 收窄、
子 agent 不受影响(只断言字符串相等会漏掉判定链没接上的情形)。

## 实测

- 真实并发探针:3 个 agent(guest / owner / guest)各 200 轮并发,
  `-race` 干净,**零串档**
- build / vet / go test ./...(62 ok / 0 fail)/ -race 全绿

## 未变的约束

档位与主体策略**只能收窄**:`CheckToolFor` 内部先跑全局策略,
所以父的黑名单对子依然有效,子无法提权。
2026-10-31 07:24:03 +08:00

263 lines
8.2 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
// 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)
}